diff --git a/.changeset/dev-sync-core.md b/.changeset/dev-sync-core.md deleted file mode 100644 index 6e193790..00000000 --- a/.changeset/dev-sync-core.md +++ /dev/null @@ -1,52 +0,0 @@ ---- -'stratal': minor ---- - -Move routing onto plain Hono with lazy OpenAPI generation, add declarative response caching on Cloudflare Workers Caching, and add per-path locale detection. - -- Build the router on plain Hono with per-route validation and lazy OpenAPI generation, dropping `@hono/zod-openapi` and `@asteasolutions/zod-to-openapi`, and move the validation surface to `zod/mini`. A minimal no-schema worker previously shipped around 599 KB of zod and OpenAPI tooling because every app extended an OpenAPI-aware Hono app and every route registered through it; on a hello-world worker the bundle drops 944 KB to 504 KB raw, and the route-registration chunk 599 KB to 44 KB. - - Request validation is attached per route only when a route declares `params`, `query`, or `body`, so schema-less routes pull in no zod at all. `ctx.param()`, `ctx.query()` and `ctx.body()` are unchanged. - - This changes the validation API, the OpenAPI generation model and `OpenAPIService.getSpec()` — see Breaking Changes below. -- Stamp an explicit `Cache-Control` header on every response, and 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. - - **This affects every app, not only those adopting caching.** Responses from routes without `@Cacheable` are stamped `Cache-Control: private, no-store`. Cloudflare Workers Caching applies RFC 9111 heuristic freshness, so a response with no `Cache-Control` at all is cached anyway — a `200` for two hours, a `404` for three minutes. Routes that already set their own `Cache-Control` are left alone. If you relied on a response having no `Cache-Control` header, set one explicitly. - - Add `@Cacheable({ ttl, swr, tags, vary })` for `GET` and `HEAD` routes, which emits `Cache-Control: public, max-age=…[, stale-while-revalidate=…]` plus `Cache-Tag`. - - Add `@PurgesCache({ tags, pathPrefixes, purgeEverything })` for mutations, which purges after a `2xx` or `3xx`. The purge is awaited, and a failure is logged with the responsible route before being rethrown as `CachePurgeError`, rather than leaving the cache silently inconsistent with the database. - - Add `ResponseCacheModule.forRoot({ defaults })` to supply `ttl`, `swr` and `vary` for every `@Cacheable` route. `@Cacheable` stays mandatory — defaults never make a route cacheable on their own. New errors: `ResponseCacheConfigError`, `CachePurgeError`, `InvalidCacheTagError`. - - Interpolate `{param.*}`, `{query.*}` and `{data.*}` 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` — commas and quotes are structural in the `Cache-Tag` header, so constrain or slugify any request-derived value before interpolating it. A `{param.*}` tag naming a segment the route does not declare is rejected at boot. - - 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, an app with cache decorators fails on its first request rather than silently not caching. -- Add cache partitioning so guarded and per-tenant routes can be cached: `@Cacheable({ partitionBy: [...] })` now works. Partitioned `GET` and `HEAD` reads are forwarded to a cached entrypoint, which places the resolved partitions in the part of the Workers Caching key that cannot be bypassed. - - Export `cachedEntrypoint(stratal)` from `stratal/workers` alongside your default export, then configure `ResponseCacheModule.forRoot({ gateway: { entrypoint: 'Cached' }, primers, partitions })`. - - `partitionBy`, `partitions` and `primers` throw at boot when `gateway.entrypoint` is absent — a partition an app cannot honour must fail loudly rather than cache per-caller data publicly. A guarded route is only ever cacheable with a non-empty `partitionBy`; `@Cacheable` on a guarded route without one is a boot error, since a guarded response differs per caller. Anything that is not a partitioned read runs inline exactly as before, and a partition that fails to resolve runs inline and is stamped `private, no-store`. - - `@PurgesCache` issues its purge over RPC to the cached entrypoint when running as the gateway, because mutations run inline in the gateway, whose cache is disabled, so an inline purge would report success and invalidate nothing. - - `gateway.entrypoint` is type-checked against your Worker's exports. Once you have run `wrangler types`, only your real, non-`default` export names are accepted, so a typo is a compile error rather than a runtime surprise. Without generated types it stays a plain string and is validated at runtime. -- Add per-path locale detection: `detection` accepts a `(path) => options` resolver, alongside the new `I18nModule.forRootAsync` and a strategy-aware `ctx.setLocale`. Different areas can now use different strategies — for example a path-localized public site with a cookie-localized `/admin` panel — which is necessary when an area's session cookie is path-scoped. - - The resolver must be a pure function of the path. Locale route variants are expanded at boot, once per route pattern, where no request exists, so the resolver is consulted both at boot (which routes get a variant) and per request (which detector runs), and both must agree. - - Only routes whose path resolves to `strategy: 'path'` get a `/:locale` variant; everything else is served at its bare path and emits locale-less URLs, with no changes needed in URL builders. - - The cookie strategy still auto-persists the `locale` cookie, now scoped by the resolved `cookieOptions`, so a per-path cookie area writes `{ path: '/admin' }` instead of the default `Path=/`. Plain `strategy: 'cookie'` behaviour is unchanged. `ctx.setLocale(locale)` overrides the locale for the current request only; persistence stays the detection layer's job. - - Adds `I18nModule.forRootAsync`, `LocaleUrlService.isPathLocalized(path)`, `LocalePathService.isPathLocalized(path)`, `LocalePathService.detectionFor(path)`, `resolveDetectionForPath()`, the `DetectionResolver`, `DetectionConfig` and `ResolvedDetection` types, and the `LOCALE_COOKIE` constant. -- Stop serving arbitrary stored content types inline from storage downloads. Downloads previously echoed an object's stored `Content-Type` back with `Content-Disposition: inline` on every disk. Because objects are served from the same origin as the application, an object stored as `text/html`, or as a scriptable `image/svg+xml`, executed against whatever session fetched it. A signed URL does not help here: it controls who may fetch an object, not what the browser does with the bytes. - - Only `application/pdf`, `image/png`, `image/jpeg`, `image/gif` and `image/webp` render inline. Everything else is returned as `application/octet-stream` with `Content-Disposition: attachment`. The allowlist is the safe set rather than a blocklist of dangerous types, so a format nobody anticipated fails closed. This is a behaviour change if you relied on a non-allowlisted type rendering in the browser — it now downloads instead. - - Every download also carries `X-Content-Type-Options: nosniff`, which stops the browser sniffing past the content type to render a disguised payload, and `Content-Security-Policy: sandbox; default-src 'none'`, so even an allowlisted file handled by a viewer or decoder gets an opaque origin with no scripting. - - Serving user-supplied content that must render is better done from a separate origin, where a compromise cannot reach the application's session. - - 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 from producing a malformed `Content-Disposition` header. Non-ASCII filenames are preserved. -- Add route visibility `groups`. `@Controller` and route options accept a `groups: string[]` label list; controller groups apply to every route, and route-level groups are appended. Resolved groups are exposed on each route's schema metadata as `RouteSchemaMeta.groups`, so the OpenAPI `routeFilter` can scope the document by group instead of by path string. Adds `getControllerGroups()` for reading a controller's declared groups. -- Accept full schema metadata in `describe()` and `named()`, not just a description string. Pass an object to set `example`, `examples`, `title`, `deprecated` and more, all of which flow through to the generated OpenAPI document. A field's location — path, query or body — is still derived from its request slot, so there is no per-field `in`. Adds the `SchemaMeta` and `SchemaMetaInput` types. -- Honour a `Response` returned by a short-circuiting middleware even when an outer middleware forwards control with `await next()` and discards the result. A middleware that returns early with `ctx.redirect(...)` or any other `Response` previously had it silently dropped, leaving the request unfinalized and throwing "Context is not finalized". This applies both to chained middlewares and to separately registered `router.use` chains. The `Next` type is widened to `() => Promise` so a forwarding middleware can `return next()` to propagate a downstream short-circuit without an unsafe cast; middlewares that `await next()` or ignore its result are unaffected. -- Fix localized multi-segment URLs matching the wrong route when two or more locales are path-prefixed. The locale segment previously swallowed deeper paths, so a request like `/fr/auth/login` matched the localized index route instead of its intended route, which could produce a redirect loop on a homepage that redirects elsewhere. -- Fix route registration failing when the router module is evaluated more than once, for example under a bundler or an SSR module runner. -- Let errors contribute structured fields to their own log entry. `ApplicationError` gains an overridable `reportContext()` hook whose return value is merged into the logged data, so an error type can surface diagnostic detail to observability without a custom `reportable()` callback. The reserved keys `message`, `name`, `stack` and `timestamp` cannot be overridden, and globally registered context still takes precedence. `SchemaValidationError` uses this to log which field failed validation and why, where previously a failed request logged only a generic "Schema validation failed" line. -- Stop `/openapi.json` failing when a route schema contains a type with no JSON Schema representation, such as `z.custom`, `z.transform`, `z.instanceof`, `z.date`, `z.map` or `z.set`. Those types now emit an empty "any" schema instead of throwing, so a single unrepresentable field no longer takes down the entire document. -- Declare `openapi3-ts` as a direct dependency. It was previously resolved only transitively, so once the transitive provider was removed a clean install such as CI could not resolve it, breaking typecheck and build. -- Remove the unused `@hono/zod-openapi` runtime dependency, trimming the install footprint and removing a stale transitive zod surface. -- Stream Quarry command output to the terminal as it is produced, instead of only after the command finishes, so long-running commands such as `inertia:dev` show progress live. Commands run inside a worker via `quarry.call()` are unaffected, and their output is still returned in the command result. -- Source `process.env` into Quarry's worker vars and secrets, so config passed through the environment resolves like any other binding. Local runs with a `.dev.vars` are unchanged, while CI and scripted runs that pass config through the environment — for example a deploy build supplying secrets as env vars — no longer fail config validation on a missing binding. -- Stop Quarry failing with `The Workers runtime failed to start` on a worker that declares a Cloudflare Workflow. The CLI host cannot own a workflow entrypoint, and cannot reach one defined in another worker in local development either, so workflow bindings are now stripped from the host and logged. Trigger workflows from the worker that defines them, through an HTTP or queue handler, rather than from the CLI host. -- Match the Workers socket contract in the Quarry CLI's Node polyfill: closing a socket now returns a promise that resolves once it is closed, and upgrading to TLS returns the upgraded socket. Both previously returned nothing, so awaiting a close in a `finally` block threw and masked the real result, and opportunistic TLS could not continue on the upgraded socket. Sending mail through the CLI was the common path affected. - -### Breaking Changes - - - **The validation API is `zod/mini`.** The `z` re-export from `stratal/validation` is removed — it only existed to share a single zod instance with the old OpenAPI integration. Import schema builders directly from `zod/mini` using named imports, e.g. `import { object, string, optional } from 'zod/mini'`, 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 the new `describe()` and `named()` helpers for attaching 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, using zod v4's native JSON Schema conversion. `OpenAPIService.getSpec()` becomes `getSpec(container)` and is async — update any direct call. The `routeFilter` option 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. - - **`CacheService.put` is now fire-and-forget and can no longer report failure.** It schedules the KV write through `waitUntil`, returns a promise that resolves immediately, and logs a rejected write instead of throwing — so `try { await cache.put(...) } catch { … }` now sees success even when the value was never stored. KV reads are edge-cached but writes commit to KV's central store and can add hundreds of milliseconds to the request, and a cache is best-effort and eventually consistent, so this is the right default for cache writes; but any write that must not be silently lost has to move to the new `CacheService.putDurable` / `TieredCacheService.putDurable`, which await the write and throw on failure. Queue idempotency claims and failed-job records already use them, since deferring those would risk double-processing and silently lost failures. Every remaining write is now non-blocking, including the KV-backed rate limiter, which writes its counter through the same path. `delete` is unchanged and remains durable and awaited: invalidations such as logout or permission busting must not be deferred. - - **Every response now carries an explicit `Cache-Control` header.** Routes without `@Cacheable` are stamped `private, no-store`. If you relied on a response having no `Cache-Control` at all, set one explicitly in the handler or a middleware — those are left untouched. - - **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. diff --git a/.changeset/dev-sync-feature-flags.md b/.changeset/dev-sync-feature-flags.md deleted file mode 100644 index 8ffedb26..00000000 --- a/.changeset/dev-sync-feature-flags.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@stratal/feature-flags': patch ---- - -Released alongside the rest of the packages; nothing changed in this one. - -- No functional or API change ships here. 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 at the previous one. Its peer ranges on `stratal` and `@stratal/inertia` are open-ended, so an existing install keeps resolving — upgrade only to keep one aligned set of versions across the framework. diff --git a/.changeset/dev-sync-framework.md b/.changeset/dev-sync-framework.md deleted file mode 100644 index 9a684f26..00000000 --- a/.changeset/dev-sync-framework.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -'@stratal/framework': minor ---- - -Share permissions with the client for Inertia access control, add a Workers-safe database pool factory, and fix role lookups against a renamed user model. - -- Share the current user's permissions and roles automatically once `accessControl` is configured, so the client can gate on them. This backs the ``, ``, `` and `` components and the `useCan`, `useRole` and `useAccess` hooks in `@stratal/inertia`, with permission strings and role names type-checked against a generated registry. -- Add `createPoolFactory(env, makePool)` to `@stratal/framework/database`, which builds the lazy pool factory a connection's `dialect` hands to its dialect instance, choosing 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, so each request owns its own pool and socket. That is mandatory on the Workers runtime, where a pool opened in one request's I/O context cannot be reused by a later request 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, which the runtime forbids. In production Hyperdrive fronts these pools and multiplexes the real server connections, so they never accumulate. - - When `STRATAL_DB_SHARED_POOL` is set, it instead memoizes one pool per connection, and tears that pool down exactly once no matter how many clients disconnect. `@stratal/testing` sets the flag automatically, because the harness runs against a direct Postgres with no Hyperdrive to multiplex — a fresh pool per resolution would accumulate until parallel test files exhausted the server's connection limit. One shared pool per connection mirrors what Hyperdrive does in production and is safe because the pool holds no per-instance state. Dev and production are unaffected. -- 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 `UserNotAuthenticatedError` on every request; pass the constant as `primers` alongside `gateway: { entrypoint }` to run `SessionVerificationMiddleware` first: `ResponseCacheModule.forRoot({ gateway: { entrypoint: 'Cached' }, primers: AUTH_GATEWAY_PRIMERS, partitions: { user: (ctx) => ctx.user().id } })`. `AUTH_GATEWAY_PRIMERS` is a `readonly` tuple, and `primers` accepts it directly — no need to spread it into a new array. Partitioned reads are then forwarded to the cached entrypoint, and a partition that fails to resolve runs inline and is stamped `private, no-store` rather than being cached publicly. On a cache miss the session lookup is paid twice, once in the gateway and once in the app's own chain; on a hit the app never runs, so only the gateway's lookup is paid. -- Adopt the plain-Hono router and `zod/mini` validation surface. Because this package re-exports the core routing and validation surface, the same migration applies — see Breaking Changes below. -- 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` model. Role lookups now resolve the user model through Better Auth regardless of ORM naming, and changing a role refreshes that user's sessions so it takes effect immediately. -- Make disposing a shared test-harness database connection idempotent, so shutdown no longer logs "Called end on pool more than once" when multiple clients share one pool. 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, 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, and `routeFilter` is now a metadata predicate `(route: RouteSchemaMeta) => boolean` instead of `(path, pathItem)`. diff --git a/.changeset/dev-sync-inertia-modal.md b/.changeset/dev-sync-inertia-modal.md deleted file mode 100644 index 4a0aff04..00000000 --- a/.changeset/dev-sync-inertia-modal.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@stratal/inertia-modal': patch ---- - -Render a modal route's background page client-only when that page is excluded from SSR. - -- Render a modal route's background page client-only when it is excluded from SSR at build time through `stratalInertia({ ssrExclude })`. A direct visit or refresh of such a modal route previously failed with `Page not found` and a 500, because the combined page was always rendered through SSR instead of honouring the same exclusion as a full-page render. The excluded page now renders client-only for the browser bundle to hydrate, so a modal route works under both SSR and client-side rendering. diff --git a/.changeset/dev-sync-inertia.md b/.changeset/dev-sync-inertia.md deleted file mode 100644 index eb0d72d9..00000000 --- a/.changeset/dev-sync-inertia.md +++ /dev/null @@ -1,34 +0,0 @@ ---- -'@stratal/inertia': minor ---- - -Add build-time SSR exclusion and client-side access control, and fix several dev-runtime failures and oversized generated types. - -- Add build-time SSR exclusion through the `stratalInertia()` Vite plugin's `ssrExclude` option, and remove the runtime SSR opt-out. Client-only pages and their heavy dependencies were previously always bundled into the worker, because the SSR page glob pulled in every page, inflating cold start; disabling SSR at runtime skipped rendering but still shipped the code. - - `stratalInertia({ ssrExclude: ['Admin/**', 'Reports/Heavy'] })` takes page-component globs, 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. - - Removes `ssr.disabled` and `ctx.withoutSsr()` — see Breaking Changes below. -- Add client-side access control: the ``, ``, `` and `` 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. - - Also fixes 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. -- Recycle the dev worker when its memory reaches a threshold, fixing frequent dev-server crashes in large apps. Under sustained HMR the Workers 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=`. Supervision runs on macOS and Linux; elsewhere it is disabled with a warning. -- Skip caching for Inertia pages that cannot be shared between callers, now that responses carry an explicit `Cache-Control` header and `@Cacheable` is available. A page is not cached when it carries flash data, is a partial reload, or contains a `once()` prop. On a cache hit the SSR render is skipped entirely, so a cached page costs no render. -- Adopt the plain-Hono router and `zod/mini` validation surface. Because this package re-exports the core routing and validation surface, the same migration applies — see Breaking Changes below. -- Render a modal route's background page client-only when that page is excluded from SSR through `ssrExclude`, and share that decision with full-page renders. A direct visit or refresh of such a modal route previously failed with `Page not found` and a 500, because the combined page was always rendered through SSR instead of honouring the exclusion. -- Export `DocumentRendererService`, registered under the new `INERTIA_TOKENS.DocumentRenderer` token, which renders a built `Page` into an HTML document `Response` and owns the single decision between streaming SSR and a client-only shell — SSR is skipped when it is unconfigured, or when the page component was build-time excluded through `ssrExclude`. `InertiaService` and `@stratal/inertia-modal` both delegate to it, so that rule lives in one place; anything rendering an Inertia document outside those paths should inject the token rather than duplicate the branch. -- Rewrite `import.meta.glob` page resolvers that pass a second argument, such as `{ eager: true }` or `{ import: 'default' }`, preserving those options. Only the bare single-argument form was matched before, so option-bearing resolvers silently shipped excluded pages into the worker bundle. -- Strip react-dom's unused legacy synchronous server renderer from the worker SSR bundle. React's server entry pulls in both the streaming renderer that Stratal uses and a synchronous renderer it never calls, and the way they are required defeats tree-shaking, so the unused build shipped in every worker. On a minimal app the SSR chunk drops around 197 KB raw and 37 KB gzipped, taking the total worker bundle from 1,664 KB to 1,471 KB raw. SSR is streaming-only, so `renderToString` and `renderToStaticMarkup` are not available in the worker. -- Fix `ReferenceError: require is not defined` returning a 500 on every SSR page under the Workers dev and SSR runtime. React 19's server entry is a CommonJS shim whose conditional require is only resolved by Vite's dependency optimizer, and because this package is excluded from that optimizer to avoid duplicate framework instances, the shim was never converted and its bare `require` reached the worker runtime. -- Fix `ReferenceError: require is not defined` and `module is not defined` under the Workers dev and SSR runtime when an app uses the ORM data layer (`@zenstackhq/orm`) or the email renderer (`@react-email/render`). Both reach CommonJS sub-dependencies through packages excluded from Vite's optimizer, so they were never converted to ESM. Each is optional and is only included when it resolves from the project. -- Fix a guest SSR render failing at app init with `createPoolFactory is not a function` under a linked or portal checkout, by excluding `@stratal/framework` from Vite's dependency optimizer alongside `@stratal/inertia` and `stratal`. The optimized database subpath lost its named exports; because the framework also re-exports the core DI tokens and Hono surface, pre-bundling it while the core is excluded could split them into two copies as well. -- Emit translation-key page props as a type reference again, instead of inlining the whole message-key union. The reference was previously lost once a key union was reached through nested object or array expansion, so such props leaked hundreds of key literals into the generated types, while genuinely narrow literal unions still stay inlined. -- 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, such as `InertiaTranslationKeys | null`, no longer defeat detection; the `null` or `undefined` member is stripped for matching and re-attached on the emitted reference. - - Props covering the full key set now reference `MessageKeys` from `stratal/i18n` rather than being widened to the prefix-filtered `InertiaTranslationKeys`. - - A prop declared in a file that does not transitively import every message namespace resolves to a strict subset with no recoverable alias; a union that is large both as a fraction of the key space and in absolute size now collapses to the type the source declares, while small hand-picked key enums stay inlined. - - Key detection is derived from the configured i18n prefixes and resolved inside the source tree, so the app's full key set is in scope. - -### 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 — move the decision into `ssrExclude`. - - **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, 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, and `routeFilter` is now a metadata predicate `(route: RouteSchemaMeta) => boolean` instead of `(path, pathItem)`. diff --git a/.changeset/dev-sync-testing.md b/.changeset/dev-sync-testing.md deleted file mode 100644 index 5addcc7e..00000000 --- a/.changeset/dev-sync-testing.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -'@stratal/testing': minor ---- - -Give each test file its own database, drain deferred work before a test finishes, and supply the cache and gateway bindings the runtime never populates. - -- Give every test **file** its own database, cloned from the migrated template and retargeted onto the Hyperdrive binding, replacing per-compile template clones. Within a file, tests reset state through `truncateDb` or the reset engine. - - 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. Per-file matches the pool's own model and makes cross-file contamination impossible by construction. - - Clones are serialized by a Postgres advisory lock, so only one clone runs at a time and contention stays bounded by the number of concurrent files. A global-setup sweep reclaims leaked databases on the next run. - - `createTestDatabaseGlobalSetup` accepts a one-time `prepare` hook to bake expensive baseline state, such as seed data or a default tenant schema, into the template once, so every file's database inherits it through the clone instead of rebuilding it per test. - - `truncateDb(name?, opts?)` accepts a `ResetOptions` preserve-list; the migration tables matching `_prisma%` are always preserved. - - There is now a single isolation model, which removes the isolation toggle and the old clone and drop helpers — see Breaking Changes below. -- Supply the `ctx.cache` binding so cache-decorated routes are testable with no configuration. Neither Miniflare nor workerd ever 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, for example to test the configuration boot guard. -- 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, because it cannot know yours, so a passing suite is not what proves your configured entrypoint is correct — the type check against your Worker's exports is. A wrong name otherwise surfaces on the first request after deploy, as a `ResponseCacheConfigError` naming the exports it can actually see. -- Drain work a request defers through `ctx.waitUntil` before `fetch()` resolves, mirroring the Workers runtime, which keeps a request alive until its deferred promises settle. A non-blocking listener's deferred side-effect, such as a database write, previously stayed in flight past the response and could still be running against a shared resource at the next request or at teardown, where disposing that resource hung the suite past the hook timeout. Deferred work now completes within the request that triggered it, and `waitUntil` semantics are otherwise unchanged. -- Drain deferred work in `close()` before tearing the app down. `fetch()` already drained per call, but the non-HTTP helpers for websockets, SSE and Quarry share the same queue, so a suite using only those could reach teardown with database writes still in flight and race the connection pool's disposal. Shutdown is now deterministic regardless of which helper enqueued the work. -- Default database-isolation projects to a 30 second hook timeout. Enabling `database` turns on real file parallelism, and each file's setup clones the template into its own database — a `CREATE DATABASE … TEMPLATE` serialized across concurrent files by a Postgres advisory lock — on top of whatever the app provisions in its own `beforeAll`, such as a tenant or seed data. Under a full worker slot that routinely exceeds Vitest's 10 second default and fails with "Hook timed out in 10000ms" even though the work would have completed. This is a floor, not a ceiling: a project with heavier setup can still raise `hookTimeout` for its own suites. -- Share one database pool per connection in the test harness, and tear it down exactly once. The harness runs against a direct Postgres with no Hyperdrive to multiplex connections, so a fresh pool per resolution would accumulate 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". Consuming apps need no test-config changes. -- Fix chunked uploads to the fake storage service failing when the body is a single-use stream, which is the shape a chunked upload delivers. The body was read twice — once to size it and once to store it — throwing `ReadableStream is disturbed`; it is now consumed exactly once. - -### 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; `globalSetup` no longer takes an isolation mode. - - **`createTestDatabaseGlobalSetup` now requires `schema`.** Add it if you were relying on the previous default. - - **The clone and drop helpers `createDatabaseFromTemplate`, `deriveDbName` and `dropDatabase` are removed.** Per-file databases are created and reclaimed automatically, so remove any manual calls; use `truncateDb` to reset state between tests within a file. diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index c8fddfea..24d3eeea 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,58 @@ # stratal +## 0.1.0 + +### Minor Changes + +- ccb3f17: Move routing onto plain Hono with lazy OpenAPI generation, add declarative response caching on Cloudflare Workers Caching, and add per-path locale detection. + + - Build the router on plain Hono with per-route validation and lazy OpenAPI generation, dropping `@hono/zod-openapi` and `@asteasolutions/zod-to-openapi`, and move the validation surface to `zod/mini`. A minimal no-schema worker previously shipped around 599 KB of zod and OpenAPI tooling because every app extended an OpenAPI-aware Hono app and every route registered through it; on a hello-world worker the bundle drops 944 KB to 504 KB raw, and the route-registration chunk 599 KB to 44 KB. + - Request validation is attached per route only when a route declares `params`, `query`, or `body`, so schema-less routes pull in no zod at all. `ctx.param()`, `ctx.query()` and `ctx.body()` are unchanged. + - This changes the validation API, the OpenAPI generation model and `OpenAPIService.getSpec()` — see Breaking Changes below. + - Stamp an explicit `Cache-Control` header on every response, and 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. + - **This affects every app, not only those adopting caching.** Responses from routes without `@Cacheable` are stamped `Cache-Control: private, no-store`. Cloudflare Workers Caching applies RFC 9111 heuristic freshness, so a response with no `Cache-Control` at all is cached anyway — a `200` for two hours, a `404` for three minutes. Routes that already set their own `Cache-Control` are left alone. If you relied on a response having no `Cache-Control` header, set one explicitly. + - Add `@Cacheable({ ttl, swr, tags, vary })` for `GET` and `HEAD` routes, which emits `Cache-Control: public, max-age=…[, stale-while-revalidate=…]` plus `Cache-Tag`. + - Add `@PurgesCache({ tags, pathPrefixes, purgeEverything })` for mutations, which purges after a `2xx` or `3xx`. The purge is awaited, and a failure is logged with the responsible route before being rethrown as `CachePurgeError`, rather than leaving the cache silently inconsistent with the database. + - Add `ResponseCacheModule.forRoot({ defaults })` to supply `ttl`, `swr` and `vary` for every `@Cacheable` route. `@Cacheable` stays mandatory — defaults never make a route cacheable on their own. New errors: `ResponseCacheConfigError`, `CachePurgeError`, `InvalidCacheTagError`. + - Interpolate `{param.*}`, `{query.*}` and `{data.*}` 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` — commas and quotes are structural in the `Cache-Tag` header, so constrain or slugify any request-derived value before interpolating it. A `{param.*}` tag naming a segment the route does not declare is rejected at boot. + - 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, an app with cache decorators fails on its first request rather than silently not caching. + - Add cache partitioning so guarded and per-tenant routes can be cached: `@Cacheable({ partitionBy: [...] })` now works. Partitioned `GET` and `HEAD` reads are forwarded to a cached entrypoint, which places the resolved partitions in the part of the Workers Caching key that cannot be bypassed. + - Export `cachedEntrypoint(stratal)` from `stratal/workers` alongside your default export, then configure `ResponseCacheModule.forRoot({ gateway: { entrypoint: 'Cached' }, primers, partitions })`. + - `partitionBy`, `partitions` and `primers` throw at boot when `gateway.entrypoint` is absent — a partition an app cannot honour must fail loudly rather than cache per-caller data publicly. A guarded route is only ever cacheable with a non-empty `partitionBy`; `@Cacheable` on a guarded route without one is a boot error, since a guarded response differs per caller. Anything that is not a partitioned read runs inline exactly as before, and a partition that fails to resolve runs inline and is stamped `private, no-store`. + - `@PurgesCache` issues its purge over RPC to the cached entrypoint when running as the gateway, because mutations run inline in the gateway, whose cache is disabled, so an inline purge would report success and invalidate nothing. + - `gateway.entrypoint` is type-checked against your Worker's exports. Once you have run `wrangler types`, only your real, non-`default` export names are accepted, so a typo is a compile error rather than a runtime surprise. Without generated types it stays a plain string and is validated at runtime. + - Add per-path locale detection: `detection` accepts a `(path) => options` resolver, alongside the new `I18nModule.forRootAsync` and a strategy-aware `ctx.setLocale`. Different areas can now use different strategies — for example a path-localized public site with a cookie-localized `/admin` panel — which is necessary when an area's session cookie is path-scoped. + - The resolver must be a pure function of the path. Locale route variants are expanded at boot, once per route pattern, where no request exists, so the resolver is consulted both at boot (which routes get a variant) and per request (which detector runs), and both must agree. + - Only routes whose path resolves to `strategy: 'path'` get a `/:locale` variant; everything else is served at its bare path and emits locale-less URLs, with no changes needed in URL builders. + - The cookie strategy still auto-persists the `locale` cookie, now scoped by the resolved `cookieOptions`, so a per-path cookie area writes `{ path: '/admin' }` instead of the default `Path=/`. Plain `strategy: 'cookie'` behaviour is unchanged. `ctx.setLocale(locale)` overrides the locale for the current request only; persistence stays the detection layer's job. + - Adds `I18nModule.forRootAsync`, `LocaleUrlService.isPathLocalized(path)`, `LocalePathService.isPathLocalized(path)`, `LocalePathService.detectionFor(path)`, `resolveDetectionForPath()`, the `DetectionResolver`, `DetectionConfig` and `ResolvedDetection` types, and the `LOCALE_COOKIE` constant. + - Stop serving arbitrary stored content types inline from storage downloads. Downloads previously echoed an object's stored `Content-Type` back with `Content-Disposition: inline` on every disk. Because objects are served from the same origin as the application, an object stored as `text/html`, or as a scriptable `image/svg+xml`, executed against whatever session fetched it. A signed URL does not help here: it controls who may fetch an object, not what the browser does with the bytes. + - Only `application/pdf`, `image/png`, `image/jpeg`, `image/gif` and `image/webp` render inline. Everything else is returned as `application/octet-stream` with `Content-Disposition: attachment`. The allowlist is the safe set rather than a blocklist of dangerous types, so a format nobody anticipated fails closed. This is a behaviour change if you relied on a non-allowlisted type rendering in the browser — it now downloads instead. + - Every download also carries `X-Content-Type-Options: nosniff`, which stops the browser sniffing past the content type to render a disguised payload, and `Content-Security-Policy: sandbox; default-src 'none'`, so even an allowlisted file handled by a viewer or decoder gets an opaque origin with no scripting. + - Serving user-supplied content that must render is better done from a separate origin, where a compromise cannot reach the application's session. + - 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 from producing a malformed `Content-Disposition` header. Non-ASCII filenames are preserved. + - Add route visibility `groups`. `@Controller` and route options accept a `groups: string[]` label list; controller groups apply to every route, and route-level groups are appended. Resolved groups are exposed on each route's schema metadata as `RouteSchemaMeta.groups`, so the OpenAPI `routeFilter` can scope the document by group instead of by path string. Adds `getControllerGroups()` for reading a controller's declared groups. + - Accept full schema metadata in `describe()` and `named()`, not just a description string. Pass an object to set `example`, `examples`, `title`, `deprecated` and more, all of which flow through to the generated OpenAPI document. A field's location — path, query or body — is still derived from its request slot, so there is no per-field `in`. Adds the `SchemaMeta` and `SchemaMetaInput` types. + - Honour a `Response` returned by a short-circuiting middleware even when an outer middleware forwards control with `await next()` and discards the result. A middleware that returns early with `ctx.redirect(...)` or any other `Response` previously had it silently dropped, leaving the request unfinalized and throwing "Context is not finalized". This applies both to chained middlewares and to separately registered `router.use` chains. The `Next` type is widened to `() => Promise` so a forwarding middleware can `return next()` to propagate a downstream short-circuit without an unsafe cast; middlewares that `await next()` or ignore its result are unaffected. + - Fix localized multi-segment URLs matching the wrong route when two or more locales are path-prefixed. The locale segment previously swallowed deeper paths, so a request like `/fr/auth/login` matched the localized index route instead of its intended route, which could produce a redirect loop on a homepage that redirects elsewhere. + - Fix route registration failing when the router module is evaluated more than once, for example under a bundler or an SSR module runner. + - Let errors contribute structured fields to their own log entry. `ApplicationError` gains an overridable `reportContext()` hook whose return value is merged into the logged data, so an error type can surface diagnostic detail to observability without a custom `reportable()` callback. The reserved keys `message`, `name`, `stack` and `timestamp` cannot be overridden, and globally registered context still takes precedence. `SchemaValidationError` uses this to log which field failed validation and why, where previously a failed request logged only a generic "Schema validation failed" line. + - Stop `/openapi.json` failing when a route schema contains a type with no JSON Schema representation, such as `z.custom`, `z.transform`, `z.instanceof`, `z.date`, `z.map` or `z.set`. Those types now emit an empty "any" schema instead of throwing, so a single unrepresentable field no longer takes down the entire document. + - Declare `openapi3-ts` as a direct dependency. It was previously resolved only transitively, so once the transitive provider was removed a clean install such as CI could not resolve it, breaking typecheck and build. + - Remove the unused `@hono/zod-openapi` runtime dependency, trimming the install footprint and removing a stale transitive zod surface. + - Stream Quarry command output to the terminal as it is produced, instead of only after the command finishes, so long-running commands such as `inertia:dev` show progress live. Commands run inside a worker via `quarry.call()` are unaffected, and their output is still returned in the command result. + - Source `process.env` into Quarry's worker vars and secrets, so config passed through the environment resolves like any other binding. Local runs with a `.dev.vars` are unchanged, while CI and scripted runs that pass config through the environment — for example a deploy build supplying secrets as env vars — no longer fail config validation on a missing binding. + - Stop Quarry failing with `The Workers runtime failed to start` on a worker that declares a Cloudflare Workflow. The CLI host cannot own a workflow entrypoint, and cannot reach one defined in another worker in local development either, so workflow bindings are now stripped from the host and logged. Trigger workflows from the worker that defines them, through an HTTP or queue handler, rather than from the CLI host. + - Match the Workers socket contract in the Quarry CLI's Node polyfill: closing a socket now returns a promise that resolves once it is closed, and upgrading to TLS returns the upgraded socket. Both previously returned nothing, so awaiting a close in a `finally` block threw and masked the real result, and opportunistic TLS could not continue on the upgraded socket. Sending mail through the CLI was the common path affected. + + ### Breaking Changes + + - **The validation API is `zod/mini`.** The `z` re-export from `stratal/validation` is removed — it only existed to share a single zod instance with the old OpenAPI integration. Import schema builders directly from `zod/mini` using named imports, e.g. `import { object, string, optional } from 'zod/mini'`, 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 the new `describe()` and `named()` helpers for attaching 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, using zod v4's native JSON Schema conversion. `OpenAPIService.getSpec()` becomes `getSpec(container)` and is async — update any direct call. The `routeFilter` option 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. + - **`CacheService.put` is now fire-and-forget and can no longer report failure.** It schedules the KV write through `waitUntil`, returns a promise that resolves immediately, and logs a rejected write instead of throwing — so `try { await cache.put(...) } catch { … }` now sees success even when the value was never stored. KV reads are edge-cached but writes commit to KV's central store and can add hundreds of milliseconds to the request, and a cache is best-effort and eventually consistent, so this is the right default for cache writes; but any write that must not be silently lost has to move to the new `CacheService.putDurable` / `TieredCacheService.putDurable`, which await the write and throw on failure. Queue idempotency claims and failed-job records already use them, since deferring those would risk double-processing and silently lost failures. Every remaining write is now non-blocking, including the KV-backed rate limiter, which writes its counter through the same path. `delete` is unchanged and remains durable and awaited: invalidations such as logout or permission busting must not be deferred. + - **Every response now carries an explicit `Cache-Control` header.** Routes without `@Cacheable` are stamped `private, no-store`. If you relied on a response having no `Cache-Control` at all, set one explicitly in the handler or a middleware — those are left untouched. + - **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. + ## 0.0.27 ### Patch Changes diff --git a/packages/core/package.json b/packages/core/package.json index 093bd084..2221cba4 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "stratal", - "version": "0.0.27", + "version": "0.1.0", "description": "A modular Cloudflare Workers framework with dependency injection, queue-based events, and type-safe configuration", "type": "module", "sideEffects": false, diff --git a/packages/feature-flags/CHANGELOG.md b/packages/feature-flags/CHANGELOG.md index d069ec3c..3ba34b65 100644 --- a/packages/feature-flags/CHANGELOG.md +++ b/packages/feature-flags/CHANGELOG.md @@ -1,5 +1,13 @@ # @stratal/feature-flags +## 0.1.0 + +### Patch Changes + +- ccb3f17: Released alongside the rest of the packages; nothing changed in this one. + + - No functional or API change ships here. 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 at the previous one. Its peer ranges on `stratal` and `@stratal/inertia` are open-ended, so an existing install keeps resolving — upgrade only to keep one aligned set of versions across the framework. + ## 0.0.27 ### Patch Changes diff --git a/packages/feature-flags/package.json b/packages/feature-flags/package.json index be89e2e1..7fda9987 100644 --- a/packages/feature-flags/package.json +++ b/packages/feature-flags/package.json @@ -1,6 +1,6 @@ { "name": "@stratal/feature-flags", - "version": "0.0.27", + "version": "0.1.0", "description": "Cloudflare Flagship feature flags for the Stratal framework — binding API wrapper with Inertia.js sharing", "type": "module", "license": "MIT", diff --git a/packages/framework/CHANGELOG.md b/packages/framework/CHANGELOG.md index 7c5395db..7fd768fe 100644 --- a/packages/framework/CHANGELOG.md +++ b/packages/framework/CHANGELOG.md @@ -1,5 +1,25 @@ # @stratal/framework +## 0.1.0 + +### Minor Changes + +- ccb3f17: Share permissions with the client for Inertia access control, add a Workers-safe database pool factory, and fix role lookups against a renamed user model. + + - Share the current user's permissions and roles automatically once `accessControl` is configured, so the client can gate on them. This backs the ``, ``, `` and `` components and the `useCan`, `useRole` and `useAccess` hooks in `@stratal/inertia`, with permission strings and role names type-checked against a generated registry. + - Add `createPoolFactory(env, makePool)` to `@stratal/framework/database`, which builds the lazy pool factory a connection's `dialect` hands to its dialect instance, choosing 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, so each request owns its own pool and socket. That is mandatory on the Workers runtime, where a pool opened in one request's I/O context cannot be reused by a later request 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, which the runtime forbids. In production Hyperdrive fronts these pools and multiplexes the real server connections, so they never accumulate. + - When `STRATAL_DB_SHARED_POOL` is set, it instead memoizes one pool per connection, and tears that pool down exactly once no matter how many clients disconnect. `@stratal/testing` sets the flag automatically, because the harness runs against a direct Postgres with no Hyperdrive to multiplex — a fresh pool per resolution would accumulate until parallel test files exhausted the server's connection limit. One shared pool per connection mirrors what Hyperdrive does in production and is safe because the pool holds no per-instance state. Dev and production are unaffected. + - 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 `UserNotAuthenticatedError` on every request; pass the constant as `primers` alongside `gateway: { entrypoint }` to run `SessionVerificationMiddleware` first: `ResponseCacheModule.forRoot({ gateway: { entrypoint: 'Cached' }, primers: AUTH_GATEWAY_PRIMERS, partitions: { user: (ctx) => ctx.user().id } })`. `AUTH_GATEWAY_PRIMERS` is a `readonly` tuple, and `primers` accepts it directly — no need to spread it into a new array. Partitioned reads are then forwarded to the cached entrypoint, and a partition that fails to resolve runs inline and is stamped `private, no-store` rather than being cached publicly. On a cache miss the session lookup is paid twice, once in the gateway and once in the app's own chain; on a hit the app never runs, so only the gateway's lookup is paid. + - Adopt the plain-Hono router and `zod/mini` validation surface. Because this package re-exports the core routing and validation surface, the same migration applies — see Breaking Changes below. + - 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` model. Role lookups now resolve the user model through Better Auth regardless of ORM naming, and changing a role refreshes that user's sessions so it takes effect immediately. + - Make disposing a shared test-harness database connection idempotent, so shutdown no longer logs "Called end on pool more than once" when multiple clients share one pool. 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, 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, and `routeFilter` is now a metadata predicate `(route: RouteSchemaMeta) => boolean` instead of `(path, pathItem)`. + ## 0.0.27 ### Patch Changes diff --git a/packages/framework/package.json b/packages/framework/package.json index d32befff..e54667a2 100644 --- a/packages/framework/package.json +++ b/packages/framework/package.json @@ -1,6 +1,6 @@ { "name": "@stratal/framework", - "version": "0.0.27", + "version": "0.1.0", "type": "module", "license": "MIT", "author": "Temitayo Fadojutimi", diff --git a/packages/inertia-modal/CHANGELOG.md b/packages/inertia-modal/CHANGELOG.md index bc73ab94..5cb3328d 100644 --- a/packages/inertia-modal/CHANGELOG.md +++ b/packages/inertia-modal/CHANGELOG.md @@ -1,5 +1,13 @@ # @stratal/inertia-modal +## 0.1.0 + +### Patch Changes + +- ccb3f17: Render a modal route's background page client-only when that page is excluded from SSR. + + - Render a modal route's background page client-only when it is excluded from SSR at build time through `stratalInertia({ ssrExclude })`. A direct visit or refresh of such a modal route previously failed with `Page not found` and a 500, because the combined page was always rendered through SSR instead of honouring the same exclusion as a full-page render. The excluded page now renders client-only for the browser bundle to hydrate, so a modal route works under both SSR and client-side rendering. + ## 0.0.27 ### Patch Changes diff --git a/packages/inertia-modal/package.json b/packages/inertia-modal/package.json index c003f3b6..fcb605c1 100644 --- a/packages/inertia-modal/package.json +++ b/packages/inertia-modal/package.json @@ -1,6 +1,6 @@ { "name": "@stratal/inertia-modal", - "version": "0.0.27", + "version": "0.1.0", "description": "Modal page primitive for Stratal Inertia — backend-driven modal dialogs", "type": "module", "license": "MIT", diff --git a/packages/inertia/CHANGELOG.md b/packages/inertia/CHANGELOG.md index c14d051d..713ed23c 100644 --- a/packages/inertia/CHANGELOG.md +++ b/packages/inertia/CHANGELOG.md @@ -1,5 +1,40 @@ # @stratal/inertia +## 0.1.0 + +### Minor Changes + +- ccb3f17: Add build-time SSR exclusion and client-side access control, and fix several dev-runtime failures and oversized generated types. + + - Add build-time SSR exclusion through the `stratalInertia()` Vite plugin's `ssrExclude` option, and remove the runtime SSR opt-out. Client-only pages and their heavy dependencies were previously always bundled into the worker, because the SSR page glob pulled in every page, inflating cold start; disabling SSR at runtime skipped rendering but still shipped the code. + - `stratalInertia({ ssrExclude: ['Admin/**', 'Reports/Heavy'] })` takes page-component globs, 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. + - Removes `ssr.disabled` and `ctx.withoutSsr()` — see Breaking Changes below. + - Add client-side access control: the ``, ``, `` and `` 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. + - Also fixes 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. + - Recycle the dev worker when its memory reaches a threshold, fixing frequent dev-server crashes in large apps. Under sustained HMR the Workers 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=`. Supervision runs on macOS and Linux; elsewhere it is disabled with a warning. + - Skip caching for Inertia pages that cannot be shared between callers, now that responses carry an explicit `Cache-Control` header and `@Cacheable` is available. A page is not cached when it carries flash data, is a partial reload, or contains a `once()` prop. On a cache hit the SSR render is skipped entirely, so a cached page costs no render. + - Adopt the plain-Hono router and `zod/mini` validation surface. Because this package re-exports the core routing and validation surface, the same migration applies — see Breaking Changes below. + - Render a modal route's background page client-only when that page is excluded from SSR through `ssrExclude`, and share that decision with full-page renders. A direct visit or refresh of such a modal route previously failed with `Page not found` and a 500, because the combined page was always rendered through SSR instead of honouring the exclusion. + - Export `DocumentRendererService`, registered under the new `INERTIA_TOKENS.DocumentRenderer` token, which renders a built `Page` into an HTML document `Response` and owns the single decision between streaming SSR and a client-only shell — SSR is skipped when it is unconfigured, or when the page component was build-time excluded through `ssrExclude`. `InertiaService` and `@stratal/inertia-modal` both delegate to it, so that rule lives in one place; anything rendering an Inertia document outside those paths should inject the token rather than duplicate the branch. + - Rewrite `import.meta.glob` page resolvers that pass a second argument, such as `{ eager: true }` or `{ import: 'default' }`, preserving those options. Only the bare single-argument form was matched before, so option-bearing resolvers silently shipped excluded pages into the worker bundle. + - Strip react-dom's unused legacy synchronous server renderer from the worker SSR bundle. React's server entry pulls in both the streaming renderer that Stratal uses and a synchronous renderer it never calls, and the way they are required defeats tree-shaking, so the unused build shipped in every worker. On a minimal app the SSR chunk drops around 197 KB raw and 37 KB gzipped, taking the total worker bundle from 1,664 KB to 1,471 KB raw. SSR is streaming-only, so `renderToString` and `renderToStaticMarkup` are not available in the worker. + - Fix `ReferenceError: require is not defined` returning a 500 on every SSR page under the Workers dev and SSR runtime. React 19's server entry is a CommonJS shim whose conditional require is only resolved by Vite's dependency optimizer, and because this package is excluded from that optimizer to avoid duplicate framework instances, the shim was never converted and its bare `require` reached the worker runtime. + - Fix `ReferenceError: require is not defined` and `module is not defined` under the Workers dev and SSR runtime when an app uses the ORM data layer (`@zenstackhq/orm`) or the email renderer (`@react-email/render`). Both reach CommonJS sub-dependencies through packages excluded from Vite's optimizer, so they were never converted to ESM. Each is optional and is only included when it resolves from the project. + - Fix a guest SSR render failing at app init with `createPoolFactory is not a function` under a linked or portal checkout, by excluding `@stratal/framework` from Vite's dependency optimizer alongside `@stratal/inertia` and `stratal`. The optimized database subpath lost its named exports; because the framework also re-exports the core DI tokens and Hono surface, pre-bundling it while the core is excluded could split them into two copies as well. + - Emit translation-key page props as a type reference again, instead of inlining the whole message-key union. The reference was previously lost once a key union was reached through nested object or array expansion, so such props leaked hundreds of key literals into the generated types, while genuinely narrow literal unions still stay inlined. + - 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, such as `InertiaTranslationKeys | null`, no longer defeat detection; the `null` or `undefined` member is stripped for matching and re-attached on the emitted reference. + - Props covering the full key set now reference `MessageKeys` from `stratal/i18n` rather than being widened to the prefix-filtered `InertiaTranslationKeys`. + - A prop declared in a file that does not transitively import every message namespace resolves to a strict subset with no recoverable alias; a union that is large both as a fraction of the key space and in absolute size now collapses to the type the source declares, while small hand-picked key enums stay inlined. + - Key detection is derived from the configured i18n prefixes and resolved inside the source tree, so the app's full key set is in scope. + + ### 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 — move the decision into `ssrExclude`. + - **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, 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, and `routeFilter` is now a metadata predicate `(route: RouteSchemaMeta) => boolean` instead of `(path, pathItem)`. + ## 0.0.27 ### Patch Changes diff --git a/packages/inertia/package.json b/packages/inertia/package.json index 77e6cf02..13b6c470 100644 --- a/packages/inertia/package.json +++ b/packages/inertia/package.json @@ -1,6 +1,6 @@ { "name": "@stratal/inertia", - "version": "0.0.27", + "version": "0.1.0", "description": "Inertia.js v3 server adapter for Stratal framework — server-driven React SPAs", "type": "module", "license": "MIT", diff --git a/packages/testing/CHANGELOG.md b/packages/testing/CHANGELOG.md index 962e46fb..6f1db1ca 100644 --- a/packages/testing/CHANGELOG.md +++ b/packages/testing/CHANGELOG.md @@ -1,5 +1,31 @@ # @stratal/testing +## 0.1.0 + +### Minor Changes + +- ccb3f17: Give each test file its own database, drain deferred work before a test finishes, and supply the cache and gateway bindings the runtime never populates. + + - Give every test **file** its own database, cloned from the migrated template and retargeted onto the Hyperdrive binding, replacing per-compile template clones. Within a file, tests reset state through `truncateDb` or the reset engine. + - 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. Per-file matches the pool's own model and makes cross-file contamination impossible by construction. + - Clones are serialized by a Postgres advisory lock, so only one clone runs at a time and contention stays bounded by the number of concurrent files. A global-setup sweep reclaims leaked databases on the next run. + - `createTestDatabaseGlobalSetup` accepts a one-time `prepare` hook to bake expensive baseline state, such as seed data or a default tenant schema, into the template once, so every file's database inherits it through the clone instead of rebuilding it per test. + - `truncateDb(name?, opts?)` accepts a `ResetOptions` preserve-list; the migration tables matching `_prisma%` are always preserved. + - There is now a single isolation model, which removes the isolation toggle and the old clone and drop helpers — see Breaking Changes below. + - Supply the `ctx.cache` binding so cache-decorated routes are testable with no configuration. Neither Miniflare nor workerd ever 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, for example to test the configuration boot guard. + - 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, because it cannot know yours, so a passing suite is not what proves your configured entrypoint is correct — the type check against your Worker's exports is. A wrong name otherwise surfaces on the first request after deploy, as a `ResponseCacheConfigError` naming the exports it can actually see. + - Drain work a request defers through `ctx.waitUntil` before `fetch()` resolves, mirroring the Workers runtime, which keeps a request alive until its deferred promises settle. A non-blocking listener's deferred side-effect, such as a database write, previously stayed in flight past the response and could still be running against a shared resource at the next request or at teardown, where disposing that resource hung the suite past the hook timeout. Deferred work now completes within the request that triggered it, and `waitUntil` semantics are otherwise unchanged. + - Drain deferred work in `close()` before tearing the app down. `fetch()` already drained per call, but the non-HTTP helpers for websockets, SSE and Quarry share the same queue, so a suite using only those could reach teardown with database writes still in flight and race the connection pool's disposal. Shutdown is now deterministic regardless of which helper enqueued the work. + - Default database-isolation projects to a 30 second hook timeout. Enabling `database` turns on real file parallelism, and each file's setup clones the template into its own database — a `CREATE DATABASE … TEMPLATE` serialized across concurrent files by a Postgres advisory lock — on top of whatever the app provisions in its own `beforeAll`, such as a tenant or seed data. Under a full worker slot that routinely exceeds Vitest's 10 second default and fails with "Hook timed out in 10000ms" even though the work would have completed. This is a floor, not a ceiling: a project with heavier setup can still raise `hookTimeout` for its own suites. + - Share one database pool per connection in the test harness, and tear it down exactly once. The harness runs against a direct Postgres with no Hyperdrive to multiplex connections, so a fresh pool per resolution would accumulate 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". Consuming apps need no test-config changes. + - Fix chunked uploads to the fake storage service failing when the body is a single-use stream, which is the shape a chunked upload delivers. The body was read twice — once to size it and once to store it — throwing `ReadableStream is disturbed`; it is now consumed exactly once. + + ### 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; `globalSetup` no longer takes an isolation mode. + - **`createTestDatabaseGlobalSetup` now requires `schema`.** Add it if you were relying on the previous default. + - **The clone and drop helpers `createDatabaseFromTemplate`, `deriveDbName` and `dropDatabase` are removed.** Per-file databases are created and reclaimed automatically, so remove any manual calls; use `truncateDb` to reset state between tests within a file. + ## 0.0.27 ### Patch Changes diff --git a/packages/testing/package.json b/packages/testing/package.json index f714cd49..4f8ac567 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -1,6 +1,6 @@ { "name": "@stratal/testing", - "version": "0.0.27", + "version": "0.1.0", "description": "Testing utilities and mocks for Stratal framework applications", "type": "module", "sideEffects": false,