diff --git a/.changeset/release-core.md b/.changeset/release-core.md deleted file mode 100644 index 22df929..0000000 --- a/.changeset/release-core.md +++ /dev/null @@ -1,66 +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. - -### 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/`, 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` 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. diff --git a/.changeset/release-feature-flags.md b/.changeset/release-feature-flags.md deleted file mode 100644 index 67fbfb2..0000000 --- a/.changeset/release-feature-flags.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -'@stratal/feature-flags': patch ---- - -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. diff --git a/.changeset/release-framework.md b/.changeset/release-framework.md deleted file mode 100644 index 9a8bd13..0000000 --- a/.changeset/release-framework.md +++ /dev/null @@ -1,56 +0,0 @@ ---- -"@stratal/framework": minor ---- - -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. - -```typescript -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 ``, ``, `` 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 `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: - - ```typescript - 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. diff --git a/.changeset/release-inertia-modal.md b/.changeset/release-inertia-modal.md deleted file mode 100644 index a9ee396..0000000 --- a/.changeset/release-inertia-modal.md +++ /dev/null @@ -1,100 +0,0 @@ ---- -"@stratal/inertia-modal": minor ---- - -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. -- `` 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. - - ```typescript - return ctx.modal('Parent/Index', { - items: ctx.scroll(() => db.$cursor.item.findMany({ cursor, take: 20, orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }] }), { matchOn: 'id' }), - }, { base: '/parent' }) - ``` - -- `` and `` 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.** `` 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. - - ```tsx - createInertiaApp({ - resolve: withModals((name) => pages[`./pages/${name}.tsx`]()), - setup: ({ el, App, props }) => hydrateRoot(el, ), - }) - ``` - -- 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: - -```typescript -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(depth?)` and `modalLevels()`. -- `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 `` 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 `` 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 `` with ``, 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.` 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: - - ```diff - -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. diff --git a/.changeset/release-inertia.md b/.changeset/release-inertia.md deleted file mode 100644 index 35c998e..0000000 --- a/.changeset/release-inertia.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -"@stratal/inertia": minor ---- - -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. - - ```typescript - 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 ``, ``, `` 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. - -### Infinite scroll - -Add **`ctx.scroll(callback, options?)`** for Inertia v3 infinite scroll, which makes `@inertiajs/react`'s `` work against a Stratal route. Until now the page carried no scroll metadata at all and the component threw before rendering. - -```typescript -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=`. 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)`. diff --git a/.changeset/release-testing.md b/.changeset/release-testing.md deleted file mode 100644 index a89beaf..0000000 --- a/.changeset/release-testing.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -"@stratal/testing": minor ---- - -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, `_w_`, 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. diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index c8fddfe..13eea10 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -1,5 +1,65 @@ # stratal +## 0.1.0 + +### 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/`, 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` 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. + ## 0.0.27 ### Patch Changes @@ -15,7 +75,6 @@ - ab95f52: Fix memory leaks that crashed the dev server (OOM) after repeated hot reloads ### Details - - Hot reloads now fully tear down the previous application before the new one boots — old and new dependency graphs no longer coexist, so memory stays flat across reloads - Instances superseded mid-boot by a newer reload now reject with `StratalSupersededError` instead of hanging forever; in-flight requests during a reload are transparently served by the replacing instance - `Container.dispose()` is now async and invokes `Symbol.asyncDispose`, `Symbol.dispose`, or `dispose()` on container-created instances, letting services release timers, sockets, and pools on shutdown — `await` it if you call it directly @@ -24,7 +83,6 @@ - bb6d3b9: Trailing-slash exclusions: `trailingSlash` accepts `{ mode, exclude }` ### Details - - `trailingSlash` application config now accepts `{ mode, exclude }` alongside a bare mode. Excluded paths are never redirected (308) and never rewritten by URL generation — for routes whose canonical form is owned externally (e.g. OAuth redirect URIs matched byte-for-byte). - String patterns are segment-aware prefixes; RegExp patterns match both slash forms of the pathname regardless of anchoring. - Exclusions match in route space: with path-based locale detection, a leading locale segment is stripped before matching, so `'/callback'` also exempts `/fr/callback` — in the redirect middleware, `Uri` helpers, and hreflang link generation. @@ -50,7 +108,6 @@ The event, cron, quarry, and seeder registries — previously registered imperatively in `Application` — are now declared as ordinary `@Module`s (`EventsModule`, `CronModule`, `QuarryModule`, `SeederModule`), consistent with every other subsystem. The `Application` constructor now only sets up the bootstrap kernel (`ExceptionHandler`, `LazyModuleLoader`, logging); all module registration happens during initialization. `application.ts` has no static subsystem imports — every built-in module is loaded via dynamic `import()`. ### Breaking Changes - - **`EventRegistry`, `QuarryRegistry`, `CronManager`, `SeederRegistry` are now `@Singleton`** (they were `@Transient` but always force-registered as singletons). This aligns the class decorator with their actual lifecycle; their canonical DI tokens are declared on the decorator. - **`SeederRegistry` now injects the `Application`** (`@inject(DI_TOKENS.Application)`) instead of being constructed manually. - **`@stratal/framework` `DatabaseModule.onInitialize` is now `async`** and loads `EventsModule` on demand via `LazyModuleLoader` (the event registry is no longer eagerly registered). No change is required for apps that use `DatabaseModule` normally. @@ -58,12 +115,10 @@ - The `schedule:list` command now lazy-loads `CronModule` via `LazyModuleLoader` rather than injecting `DI_TOKENS.Cron`; with no jobs registered it prints "No cron jobs found" instead of failing to resolve. - 13b0e8d: Replace the email provider layer with a built-in Cloudflare Workers-compatible SMTP client and defer React Email rendering - - Email is now sent through a built-in SMTP client and MIME builder, removing the runtime dependency on `nodemailer`. - `@react-email/render` is loaded on demand only when sending a React template, reducing cold-start overhead for requests that don't send email. ### Breaking Changes - - **Resend provider removed.** Switch to SMTP. Remove the `provider` and `apiKey` options from your email configuration and remove `resend` from your dependencies. - **SMTP configuration uses a connection URL.** Replace individual `host`/`port`/`secure`/`username`/`password` fields with a single `url`: @@ -84,7 +139,7 @@ ```ts const ref = await loader.load(() => - import("./reports.module").then((m) => m.ReportsModule) + import("./reports.module").then((m) => m.ReportsModule), ); ref.get(ReportService); ``` @@ -94,13 +149,11 @@ If a lazy module provides a token that another module has already bound on the global container, the existing binding is kept and the colliding lazy provider is ignored (with a warning) — a lazy module cannot silently clobber an already-registered token. ### Breaking Changes - - **Built-in subsystems are no longer registered eagerly at boot.** `I18nModule`, `QueueModule`, `CacheModule`, `OpenAPIModule`, the cron manager, and router services are now loaded via dynamic `import()` at their trigger points (i18n/routing on the first HTTP request, queue on the first batch, cron on the first scheduled invocation or when the app declares jobs). HTTP-only apps no longer evaluate queue/cron code at cold start. - **`CacheService` is no longer globally available unless `CacheModule` is loaded.** `RateLimiterModule` now imports `CacheModule` itself; apps that relied on the implicit global `CacheService` must import `CacheModule` (or use `LazyModuleLoader`). - **`Application.initializeHandlers()` is removed.** Non-HTTP entrypoints (Durable Objects, Workflows, WorkerEntrypoints) now use `Application.ensureScopedHandlers()` via the internal `runInScope` helper — no action required for typical apps. - 13b0e8d: Add locale-aware URL generation for path-prefixed and querystring localized routing - - Route URL generation now applies the active locale automatically — e.g. `uri.route('posts.show', { locale: 'es' })` produces `/es/posts/...` when locale prefixing is enabled. - New `LocaleUrlConfig` and a locale-aware URL service for producing locale variants of any URL (used for hreflang alternates, canonical URLs, sitemaps, and redirects). - Configurable trailing-slash handling for consistent URL formatting. @@ -108,7 +161,6 @@ - 13b0e8d: Fix correctness and security issues found in review. Queue: - - Retry the correct binding: dispatch stamps the producer binding into message metadata and failed jobs record it, so `queue:retry` re-enqueues through the Cloudflare binding instead of the queue name (which is not a valid binding key and broke retry whenever the two differed). A message with no binding metadata is logged and acked rather than stored as an unretryable job. - Honor the documented retry budget: `maxRetries` now counts retries correctly against Cloudflare's 1-based `message.attempts` (previously gave one fewer retry than configured). - Derive idempotency keys from an order-stable serialization of `type` + `payload`, so payloads that differ only in key order dedupe correctly. @@ -116,7 +168,6 @@ - Documented that delivery is at-least-once with best-effort de-duplication (not exactly-once), since the processed marker is written only after a handler succeeds and KV is eventually consistent — handlers must be idempotent. Email (SMTP): - - Upgrade STARTTLS onto the socket `startTls()` returns: the original socket is closed by the runtime, so the post-upgrade reader/writer are re-derived from the new secure socket and any pre-handshake bytes are discarded (fixes a broken `smtp://` STARTTLS path on real Workers and closes the STARTTLS plaintext-injection vector). - Refuse to send credentials over an unencrypted connection: an `smtp://` server that doesn't offer STARTTLS now fails loudly instead of leaking the password (blocks STARTTLS-stripping downgrades). Credential-free connections (e.g. local Mailpit) are unaffected. - AUTH is gated on the server's advertised mechanisms and supports both `PLAIN` and `LOGIN`; usernames are percent-decoded like passwords. @@ -124,46 +175,38 @@ - MIME builder strips CR/LF from headers, escapes/RFC 2231-encodes attachment filenames (prevents header injection), base64-encodes message bodies (fixes long-line corruption), and rejects envelope addresses containing whitespace or angle brackets (prevents `MAIL FROM`/`RCPT TO` desync). Inertia SEO: - - `titleTemplate` substitutes every `%s` and treats `$`-sequences in the title literally. - Inject head/body content via function replacements, so SEO/page content containing `$`-sequences (`$$`, `$&`, `` $` ``, `$'`) is no longer corrupted or able to splice a template placeholder back into the output. - Drop unsafe attribute names — including inline event handlers (`on*`) — from custom `meta`/`link` entries (prevents tag breakout server-side, `setAttribute` errors during client head-sync, and developer-supplied event-handler attributes). Feature flags: - - `FeatureFlagService.use()` binds the target app exactly once. Database (framework): - - The reentrant `$transaction` proxy forwards the receiver for non-transaction property access. Testing: - - `TestingModule.close()` drops the isolated per-file database even if shutdown throws; the stale-database sweep escapes LIKE metacharacters so a prefix containing `_` can't over-match. DI: - - Construct singletons against the root container so they can never capture a request-scoped dependency (which would leak one request's state across every later request); an illegal singleton→request dependency now throws loudly. - Detect circular dependencies and throw a clear error naming the cycle instead of overflowing the stack. - `tryResolve` only swallows "no provider"; a registered provider that throws while constructing now surfaces the real error instead of injecting `undefined`. - Request-cache invalidation tracks transitive constructor dependencies, so re-registering a value rebuilds cached services that depend on it through a transient intermediary. Quarry dev runtime: - - Persist every durable plugin (KV, D1, R2, Durable Objects, cache) under `.wrangler/state/v3`, matching `wrangler dev` (previously only R2 was persisted); load `.env.local` / `.env..local` into `process.env` for full parity. - The `cloudflare:sockets` STARTTLS shim re-attaches the stream error handler to the upgraded socket, so post-upgrade connection errors still surface. - 13b0e8d: Align the Quarry CLI dev runtime with `wrangler dev` The Quarry CLI now builds its local environment directly from your Wrangler config via Miniflare, so bindings, `vars`, and `.dev.vars` / `.env` files resolve exactly as they do under `wrangler dev` (including environment-specific `.env.` files loaded by `--env`). - - **Shared R2 state** — R2 buckets now persist to `.wrangler/state/v3/r2`, so data written by Quarry commands and `wrangler dev` is shared. - **Parallel dev environments** — set `WRANGLER_REGISTRY_PATH` to isolate the dev service registry, allowing multiple dev environments to run side by side without service-binding collisions. Quarry also discovers a running `wrangler dev` session so service bindings resolve against it. - **SMTP/socket support** — outbound TCP/TLS (e.g. sending email over SMTP) now works when running under Quarry. - **Queues and events in commands** — CLI commands can now dispatch to queues and emit events, with listeners wired automatically. - 13b0e8d: Add failed-job storage, idempotent dispatch, and queue management CLI commands - - Messages that exhaust their retry attempts are persisted to a KV-backed store so they can be inspected and replayed. - New Quarry commands to manage failed jobs: - `queue:failed` — list failed jobs (filter with `--queue`, cap with `--limit`). @@ -175,13 +218,11 @@ - Queue state (idempotency claims and failed jobs) is stored in a KV namespace that defaults to the `CACHE` binding; override it with `store: { binding: 'YOUR_KV' }` in the queue module options. - 13b0e8d: Add precognition request validation, a safe WebSocket send, and cron misconfiguration warnings - - **Precognition** — send a `Precognition: true` header to run a route's validators (across all parameters, including localized/prefixed routes) and get a `204` without executing the handler, enabling live form validation. - **`trySend()`** — gateways can now send a WebSocket message only when the socket is open, returning `false` instead of throwing for closed connections. - A warning is now logged when a cron job is registered without a `schedule`, instead of silently skipping it. - 13b0e8d: Add an opt-in isolate-local L1 cache tier and back queue idempotency with it. - - New `TieredCacheService` (`CACHE_TOKENS.TieredCacheService`) layers an isolate-local in-memory L1 over `CacheService` (KV). It gives read-after-write coherence within an isolate, closing KV's eventual-consistency gap (a `get` can otherwise return an edge-cached value for up to ~60s after a `put`). Same API as `CacheService` plus `binding(name)`, which memoizes a tiered instance per binding so each KV namespace keeps a stable, isolate-lifetime L1. - L1 semantics: caches string-backed values only (`text`/`json`); `put`/`delete` are write-through; `text` reads back-populate; `arrayBuffer`/`stream` reads and non-string writes bypass and invalidate L1; `getWithMetadata`/`list` always read KV. FIFO-bounded. - Queue idempotency claims and failed-job storage (`QueueStore`) now run through `TieredCacheService`, so a message redelivered to the same warm isolate is de-duplicated even inside KV's consistency window. Delivery remains at-least-once with best-effort de-duplication, not exactly-once. `QueueModule` now imports `CacheModule`. @@ -196,7 +237,6 @@ - 1658945: Overhaul error handling, rename queue "name" to "binding", add i18n CLI commands, and introduce QuarryRunner ### Breaking Changes - - **`ApplicationError`** — Constructor changed from `(i18nKey, code, metadata?)` to `(message?, cause?)`. Remove error code and i18n key arguments from any subclass `super()` calls. The `code`, `metadata`, `toErrorResponse()`, `toJSON()`, `report()`, and `render()` members are removed. - **Error codes removed** — `ERROR_CODES` registry and `ErrorCode` type are deleted. Use plain error messages or custom properties on `HttpException` subclasses instead. - **Per-module error consolidation** — Individual error classes (e.g. `QueueBindingNotFoundError`, `CacheGetError`, `ConfigModuleNotInitializedError`) are replaced by single per-module error classes (`QueueError`, `CacheError`, `ConfigError`, etc.). Update any `catch` blocks or `instanceof` checks. @@ -208,7 +248,6 @@ - 4b273ea: Replace tsyringe and reflect-metadata with a built-in dependency injection container and switch i18n engine from @intlify/core-base to intl-messageformat ### Breaking Changes - - **`tsyringe` and `reflect-metadata` removed** — All imports from `tsyringe` (`inject`, `injectable`, `container`, `delay`, `Lifecycle`) must be replaced with equivalents from `stratal/di`. Remove `reflect-metadata` from your dependencies and imports. - **`@Transient` decorator renamed to `@Request`** — Update all `@Transient(TOKEN)` usages to `@Request(TOKEN)` for request-scoped services. - **`delay()` replaced by `lazy()`** — Replace `delay(() => MyClass)` with `lazy(() => MyClass)` from `stratal/di`. @@ -252,7 +291,6 @@ Also exports `CUID2_REGEX` for callers composing the pattern into custom schemas. - f8c61e1: Add `RateLimiterModule` for request throttling with KV and in-memory stores - - New opt-in `RateLimiterModule` configurable with `forRoot({ store: 'kv', binding })` or `forRoot({ store: 'memory' })` (or a custom `IRateLimiterStore`). - `Limit` builder API with `perSecond`, `perSeconds`, `perMinute`, `perMinutes`, `perHour`, `perDay`, and `none()` helpers; `.by(key)` scopes per-actor and `.response(handler)` overrides the default 429. - `RateLimiterRegistry.for(name, resolver)` defines named limiters; apply them with `router.throttle(name)` or the `@RateLimit(name)` decorator on controllers and route methods. @@ -260,14 +298,12 @@ - Misconfiguration surfaces at boot (missing `forRoot`) rather than on the first throttled request. - f8c61e1: Add Cloudflare request properties and full-record access on `RouterContext` - - New `ctx.cf` getter exposes Cloudflare-provided request properties (geo, TLS, bot management, etc.) as `CfProperties`. - `ctx.param()` (no args) now returns the full validated param record as `Record`. The single-key overload (`ctx.param('id')`) is unchanged. The same overload is available on `GatewayContext` for WebSocket gateways. - f8c61e1: Add `trailingSlash` application option for canonical URL handling A new `trailingSlash` field on `ApplicationConfig` controls how incoming paths and generated URLs handle a trailing `/`: - - `'ignore'` (default) — both `/foo` and `/foo/` resolve to the same route; URL helpers leave paths unchanged. - `'always'` — non-trailing requests are 308-redirected to the trailing-slash form; URL helpers append `/`. Paths whose last segment looks file-like (e.g. `/api/openapi.json`) are skipped. - `'never'` — trailing requests are 308-redirected to the non-trailing form; URL helpers strip a single trailing `/`. @@ -281,7 +317,6 @@ - 3b16f5b: Resolve cron jobs from request-scoped DI container at execution time ### Breaking Changes - - `CronManager.registerJob()` now accepts `(schedule, jobClass)` instead of a `CronJob` instance. Jobs are resolved from the container at execution time, ensuring request-scoped dependencies (e.g. database connections) are properly scoped. - `CronManager.executeScheduled()` now requires a `Container` as its second argument. - `CronManager.getJobsForSchedule()` returns `RegisteredJob[]` instead of `CronJob[]`. @@ -291,7 +326,6 @@ **Why:** Multiple modules augmenting `AppMessages` with a shared top-level parent (e.g., `errors.auth`, `errors.uploads`, `errors.branding`) collided with TypeScript error **TS2717** ("Subsequent property declarations must have the same type"). Interface merging adds new properties across declarations but requires same-named properties to have structurally identical types — it does not deep-merge nested shapes. **What changed:** - - Replaced the single augmentable `AppMessages` interface with an `AppMessageNamespaces` keyed registry. Each module declares its own distinct top-level key (Laravel-style package namespacing). Because each declaration adds a different property, interface merging accepts them all. - `AppMessages` is now derived: `{ [K in keyof AppMessageNamespaces]: AppMessageNamespaces[K] }`. - Access keys are unchanged dot-notation — `i18n.t('auth.errors.invalidCredentials')` — so no custom resolver is needed. @@ -319,7 +353,6 @@ ``` **Framework package moves:** - - All `errors.auth.*` keys (previously split between `stratal` core and `@stratal/framework`) now live in the auth module as `auth.errors.*`. `errors.auth.org.*` → `auth.org.*`. The `errors.auth.*` namespace has been removed from `stratal`'s core messages. - `@stratal/framework`'s `DatabaseModule` now registers its `database.*` validation messages via `I18nModule.registerMessages` (previously the messages file existed but was never wired up). - `@stratal/inertia-modal`'s `errors.modal.*` key moved to `modal.errors.*`. @@ -339,13 +372,11 @@ No runtime API change: `I18nModule.registerMessages(messages)` keeps its existing signature, and deep-merge behavior is unchanged. Locale-only contributions that override core's built-in `errors.*` / `common.*` / etc. continue to work. - 3b16f5b: Add `Macroable` base class for dynamic method registration and introduce `ConfigStore` for request-scoped configuration - - Add `Macroable` class (inspired by Laravel/AdonisJS) that supports `macro()`, `instanceProperty()`, and `getter()` for runtime method registration with full inheritance support. - Introduce `ConfigStore` as a singleton source of truth for validated config, making `ConfigService` request-scoped with per-request overrides via `set()` and `reset()`. - `ConfigService` now extends `Macroable`, allowing apps to add domain-specific getters and methods. - 3b16f5b: Improve middleware error handling and defer routing initialization for better performance - - Add `MiddlewareNextCalledMultipleTimesError` to detect and report when `next()` is called more than once in a middleware. - Defer routing and handler initialization until first request for improved cold-start performance. - Improve `isApplicationError` type guard with structural fallback for cross-module boundary cases. @@ -361,14 +392,12 @@ - 3b16f5b: Migrate storage from AWS S3 to Cloudflare R2 for all storage operations ### Breaking Changes - - The `S3StorageProvider` has been removed. All storage operations now use the native Cloudflare R2 API via `R2StorageProvider`. - Storage configuration no longer requires AWS credentials or S3 endpoint settings. Instead, configure an R2 bucket binding in your `wrangler.toml` and reference it in your storage config. - Presigned URLs now require the `APP_SECRET` environment variable instead of AWS credentials. - The `StorageProviderNotSupportedError` has been replaced with `R2BindingNotFoundError` and `R2PresignedUrlSecretMissingError`. ### Migration - 1. Replace any `S3StorageProvider` references with `R2StorageProvider`. 2. Update your `wrangler.toml` to bind your R2 bucket. 3. Set `APP_SECRET` in your environment for presigned URL support. @@ -382,7 +411,6 @@ - 17f8675: Add `ExceptionHandler` with customizable error reporting, rendering, and throttling support ### Details - - Introduce `ExceptionHandler` base class with `report()`, `render()`, `shouldReport()`, and `throttle()` hooks - Add `HttpException` class for structured HTTP error responses with fluent API - Add `ExceptionContext` for collecting contextual metadata during error handling @@ -391,13 +419,11 @@ - Streamline OpenAPI service and routing metadata handling ### Breaking Changes - - `GlobalErrorHandler` has been removed. Migrate to `ExceptionHandler` by extending the base class and implementing the `render()` hook for custom error responses. - c9176ea: Enhance i18n locale detection with configurable strategies and message loader service ### Details - - Support multiple locale detection strategies: cookie, header, querystring, and path-based - Add `MessageLoaderService` for dynamic message loading and registration - Add `stratal/i18n/utils` subpath export for i18n setup utilities @@ -405,7 +431,6 @@ - c9176ea: Add Laravel-style routing with named routes, URI generation, signed URLs, domain routing, and response validation ### Details - - Add `Uri` service for generating URLs from named routes with parameter binding - Add signed URL support with HMAC-based signature generation and verification - Add domain-based routing with `@Route({ domain })` and domain middleware @@ -417,7 +442,6 @@ - Replace module-level middleware system with router-scoped middleware via `RouteConfigurable` ### Breaking Changes - - The `stratal/middleware` subpath export has been removed. Middleware is now configured through the router using `RouteConfigurable` instead of `MiddlewareConfigurable`. Implement `configureRoutes(router: Router)` on your module and use `router.use(...)` to apply middleware. ## 0.0.17 @@ -427,7 +451,6 @@ - [#147](https://github.com/strataljs/stratal/pull/147) [`7f2772b`](https://github.com/strataljs/stratal/commit/7f2772ba90a9b6a91603f79293d384e972864125) Thanks [@adesege](https://github.com/adesege)! - Add MCP server support and API CLI commands ### Details - - Add `mcp:serve` command to start a stdio MCP server that exposes OpenAPI routes as tools - Add `mcp:tools` command to list available MCP tools derived from the OpenAPI spec - Add `api` command to invoke API endpoints directly from the CLI @@ -438,7 +461,6 @@ - [#145](https://github.com/strataljs/stratal/pull/145) [`79e05de`](https://github.com/strataljs/stratal/commit/79e05de7482c925323a2f37a00e47929133a979f) Thanks [@adesege](https://github.com/adesege)! - Enhance Quarry CLI with dynamic command generation, improved help output, and usage generator ### Details - - Replace static `ListCommand` with dynamic command generation via `createDynamicCommands` that auto-registers user-defined commands with Clipanion - Improve `HelpCommand` to display detailed usage for specific commands including arguments, options, and aliases - Add `UsageGenerator` for rendering formatted command usage with ANSI colors (name, description, arguments, options sections) @@ -451,13 +473,11 @@ - [`916fd90`](https://github.com/strataljs/stratal/commit/916fd90727a06b5ce7c0397467fe9dc1f859f841) Thanks [@adesege](https://github.com/adesege)! - Add `I18nModule.registerMessages()` for decentralized i18n message registration ### Details - - Any module can now call `I18nModule.registerMessages()` to contribute translations, enabling package-level message ownership - Messages are deep-merged across all registrations in order — later calls override earlier ones at leaf level - `RouterContext.json()` now accepts `null` and automatically returns 204 No Content ### Breaking Changes - - Remove `messages` option from `I18nModule.forRoot()` — use `I18nModule.registerMessages()` instead **Before:** @@ -479,7 +499,6 @@ - [`cbfce8b`](https://github.com/strataljs/stratal/commit/cbfce8b3a3517b60d94f500c5dc1ef68d8ee76f4) Thanks [@adesege](https://github.com/adesege)! - Support multiple seeder names in `db:seed` command via variadic `{names*}` argument ### Details - - Change `db:seed {name?}` to `db:seed {names*}` to accept multiple seeder class names in a single invocation - When `--all` is used with named seeders, warn and ignore the names - Iterate over all provided names, running each seeder sequentially @@ -491,7 +510,6 @@ - [#144](https://github.com/strataljs/stratal/pull/144) [`3dd0bc8`](https://github.com/strataljs/stratal/commit/3dd0bc84c8638db30db7b70f3532a44aa187ace8) Thanks [@adesege](https://github.com/adesege)! - Enhance Quarry CLI with dynamic command generation, improved help output, and usage generator ### Details - - Replace static `ListCommand` with dynamic command generation via `createDynamicCommands` that auto-registers user-defined commands with Clipanion - Improve `HelpCommand` to display detailed usage for specific commands including arguments, options, and aliases - Add `UsageGenerator` for rendering formatted command usage with ANSI colors (name, description, arguments, options sections) @@ -502,7 +520,6 @@ - [#142](https://github.com/strataljs/stratal/pull/142) [`4b958e2`](https://github.com/strataljs/stratal/commit/4b958e250c99681a99a34a398fbf706546f556cc) Thanks [@adesege](https://github.com/adesege)! - Lazy-load S3 storage provider and enhance StorageManagerService with promise deduplication ### Details - - `StorageManager.getProvider()` is now async and dynamically imports `S3StorageProvider` to avoid loading AWS SDK at module evaluation time - Add promise deduplication to prevent concurrent `getProvider` calls from creating multiple provider instances - Register `StorageManager` as a singleton to share cached providers across requests @@ -517,7 +534,6 @@ - [#125](https://github.com/strataljs/stratal/pull/125) [`0731e99`](https://github.com/strataljs/stratal/commit/0731e99c3e0c96f988387611f0ef8559b63d7bd8) Thanks [@adesege](https://github.com/adesege)! - Introduce Quarry command framework with auto-discovery and Clipanion-based CLI ### Details - - Add `Command` base class with declarative signature parsing (arguments, options, flags) - Add `QuarryRegistry` for command registration, discovery from modules, and execution - Add `quarry` CLI bin (`npx quarry`) with Clipanion-based command routing @@ -532,7 +548,6 @@ - [#134](https://github.com/strataljs/stratal/pull/134) [`52f1daa`](https://github.com/strataljs/stratal/commit/52f1daa981f5a38b983bb3c14abfefb663eb6941) Thanks [@adesege](https://github.com/adesege)! - Move seeders from standalone `@stratal/seeders` package into core as `stratal/seeder` ### Details - - Add `Seeder` abstract base class with `run()` and `call(OtherSeeder)` methods - Add `SeederRegistry` for seeder registration and execution - Auto-discover seeders from module `providers` (any class extending `Seeder`) @@ -558,7 +573,6 @@ - [#120](https://github.com/strataljs/stratal/pull/120) [`8d0df50`](https://github.com/strataljs/stratal/commit/8d0df506411bc725ef4e4eaf4efdb314b3384d98) Thanks [@adesege](https://github.com/adesege)! - Add WebSocket gateway support with `@Gateway`, `@OnMessage`, `@OnClose`, and `@OnError` decorators ### Details - - `@Gateway(path, options?)` decorator marks a class as a WebSocket gateway, reusing controller route metadata for middleware compatibility. Accepts optional `GatewayOptions` with `version` support (single, array, or `VERSION_NEUTRAL`) - `@OnMessage()`, `@OnClose()`, `@OnError()` method decorators wire handler methods to WebSocket events - `GatewayContext` extends `RouterContext` with WebSocket-specific methods (`send()`, `close()`, `readyState`) @@ -570,7 +584,6 @@ - [#117](https://github.com/strataljs/stratal/pull/117) [`527f675`](https://github.com/strataljs/stratal/commit/527f675ea3b4cdb98165cbe1f81e820fa9e79490) Thanks [@adesege](https://github.com/adesege)! - Add configurable content type support for request and response bodies in route definitions ### Details - - Add `RouteBodyObject` and `RouteResponseObject` types with optional `contentType` field - Support `{ schema, contentType }` object form for `body` and `response` in `@Route()` config - Bare `ZodType` values default to `application/json` (backward-compatible) @@ -581,7 +594,6 @@ - [#115](https://github.com/strataljs/stratal/pull/115) [`bb99119`](https://github.com/strataljs/stratal/commit/bb991196dbcc55963d16ee1a6f5db580c18c796a) Thanks [@adesege](https://github.com/adesege)! - Replace Scalar with Swagger UI as the default OpenAPI docs renderer and add pluggable UI support ### Details - - Replace `@scalar/hono-api-reference` dependency with `@hono/swagger-ui` - Add `OpenAPIUIRenderer` type for custom docs UI renderers - Add `ui` option to `OpenAPIModuleOptions` with `path` and `renderer` fields @@ -589,7 +601,6 @@ - Remove `docsPath` option in favor of `ui.path` (default remains `/api/docs`) ### Breaking Changes - - The `docsPath` option in `OpenAPIModuleOptions` has been removed. Use `ui.path` instead: ```ts @@ -605,7 +616,6 @@ - [#119](https://github.com/strataljs/stratal/pull/119) [`957de6e`](https://github.com/strataljs/stratal/commit/957de6e88684344bf26e95d03187345bf77f4f52) Thanks [@adesege](https://github.com/adesege)! - Remove redundant `i18nKey` property from `ApplicationError` and use `Error.message` instead ### Details - - Remove `i18nKey` property — the i18n key is already stored in `Error.message` via `super(i18nKey)` - `toErrorResponse()` now uses `this.message` for fallback and stack trace rewriting - `GlobalErrorHandler.translateError()` casts `error.message as MessageKeys` for i18n lookup @@ -614,7 +624,6 @@ - [#118](https://github.com/strataljs/stratal/pull/118) [`0ade941`](https://github.com/strataljs/stratal/commit/0ade94162f9058e9230039fa72efbbf3e57cf572) Thanks [@adesege](https://github.com/adesege)! - Add streaming response methods (`stream`, `streamText`, `streamSSE`) to RouterContext ### Details - - `stream()` — generic/binary streaming via Hono's `stream` helper - `streamText()` — text streaming with automatic `Content-Encoding: Identity` for Cloudflare Workers compatibility - `streamSSE()` — Server-Sent Events streaming with automatic `Content-Encoding: Identity` for Cloudflare Workers compatibility @@ -627,7 +636,6 @@ - [#113](https://github.com/strataljs/stratal/pull/113) [`11b0da9`](https://github.com/strataljs/stratal/commit/11b0da97ef436bffef592fbc34685bbcc85d7ef7) Thanks [@adesege](https://github.com/adesege)! - Add HTTP method decorators (`@Get`, `@Post`, `@Put`, `@Patch`, `@Delete`, `@All`) for explicit route handling as an alternative to convention-based `@Route()` routing ### Details - - **stratal** - Add `@Get`, `@Post`, `@Put`, `@Patch`, `@Delete`, and `@All` decorators that accept an explicit path and optional `RouteConfig` - Routes decorated with `@All` are automatically hidden from OpenAPI documentation @@ -640,7 +648,6 @@ - [#114](https://github.com/strataljs/stratal/pull/114) [`e1a2ba2`](https://github.com/strataljs/stratal/commit/e1a2ba2da883481d192a15b8015456705982d683) Thanks [@adesege](https://github.com/adesege)! - Add URI-based API versioning support with configurable version prefix and default version ### Details - - **stratal** - Add `versioning` option to `ApplicationConfig` to enable URI-based versioning (e.g., `/v1/users`, `/v2/users`) - Add `version` option to `ControllerOptions` for per-controller version assignment (single, array, or `VERSION_NEUTRAL`) @@ -655,9 +662,7 @@ - [#97](https://github.com/strataljs/stratal/pull/97) [`d58b878`](https://github.com/strataljs/stratal/commit/d58b8782848562a50b79cd558eaf01978aa77f26) Thanks [@adesege](https://github.com/adesege)! - Add `stratalTest()` vitest plugin and migrate fetch mocking from Cloudflare's undici-based `fetchMock` to MSW ### Details - - **@stratal/testing** - - Add `@stratal/testing/vitest-plugin` sub-export with `stratalTest()` — wraps `cloudflareTest` with Stratal defaults (tslib alias, ZenStack mocks, SSR externals) - Replace `FetchMock`/`createFetchMock` with `MockFetch`/`createMockFetch` backed by MSW (`setupServer`) - Re-export `http` and `HttpResponse` from `msw` for convenience @@ -665,7 +670,6 @@ - Bump vitest peer dependency from `^3.2.0` to `^4.1.0` - **stratal** - - Update test mocks to use class syntax for Vitest 4 compatibility - Bump dependencies: `@intlify/*`, `@scalar/hono-api-reference`, `hono`, `@aws-sdk/*`, `vitest` @@ -674,7 +678,6 @@ - Bump dependencies: `better-auth`, `@zenstackhq/*`, `wrangler`, `vitest` ### Breaking Changes - - **@stratal/testing**: `FetchMock` and `createFetchMock` are removed. Use `MockFetch`/`createMockFetch` instead. The new API uses MSW lifecycle methods (`listen`/`reset`/`close`) instead of `activate`/`disableNetConnect`/`deactivate`. - **@stratal/testing**: Vitest peer dependency is now `^4.1.0` (was `^3.2.0`). @@ -691,7 +694,6 @@ - [#88](https://github.com/strataljs/stratal/pull/88) [`3329d20`](https://github.com/strataljs/stratal/commit/3329d20658ea6a6f7cadbbb3efb7630b1cca9ad2) Thanks [@adesege](https://github.com/adesege)! - Add worker base classes (`StratalDurableObject`, `StratalWorkerEntrypoint`, `StratalWorkflow`) with DI support and request-scoped containers ### Details - - Introduce `stratal/workers` sub-path export with `StratalDurableObject`, `StratalWorkerEntrypoint`, `StratalWorkflow`, and `runInScope` helper - Add `Stratal.resolveApplication()` static method for worker classes to access the DI container - Add `StratalNotInitializedError` for when `resolveApplication()` is called before Stratal is instantiated @@ -704,7 +706,6 @@ - [#86](https://github.com/strataljs/stratal/pull/86) [`c0d9313`](https://github.com/strataljs/stratal/commit/c0d9313b30272eece8a4596718b7d4c1b442c221) Thanks [@adesege](https://github.com/adesege)! - Remove default CORS middleware from HonoApp ### Breaking Changes - - **stratal**: `HonoApp` no longer applies `cors()` middleware by default. If your application relies on the built-in CORS handling, add it explicitly via a custom middleware in your module's `configure()` method or by registering it globally. ## 0.0.8 @@ -718,7 +719,6 @@ ### Breaking Changes **`stratal` (core)** - - **Removed `RequestContextStore`** — The `AsyncLocalStorage`-based request context propagation is eliminated. This removes the dependency on the `nodejs_als` compatibility flag in Cloudflare Workers. - **Removed `RouterService` and `RequestScopeService`** — Replaced by `HonoApp`, a subclass of `OpenAPIHono` that directly integrates request scoping, middleware class support, and global error handling. - **Removed `RouterAlreadyConfiguredError` and `RouterNotConfiguredError`** — Replaced by `HonoAppAlreadyConfiguredError`. @@ -730,12 +730,10 @@ - **New `HonoApp` class** — Extends `OpenAPIHono` with Stratal concerns; supports `Constructor` in `use()` via module augmentation. **`@stratal/framework`** - - **`DatabaseConnectionConfig.dialect` changed from `Dialect` to `() => Dialect`** — Database connections now take a factory function for lazy dialect/pool creation. - **Caching strategy changed from `instancePerContainerCachingFactory` to `instanceCachingFactory`**. **`@stratal/testing`** - - **`TestingModule.runInRequestScope()` callback now receives a `container` parameter** — Update all callbacks to use the passed container for service resolution. - **`TestingModule.fetch()` now routes through `HonoApp`** instead of `RouterService`. - **`TestingModuleBuilder.compile()` now applies overrides before `initialize()`** — Fixes issue where overrides were applied after initialization. @@ -743,7 +741,6 @@ ### Minor Changes **`@stratal/seeders`** - - Updated `executeSeeder()` to use the explicit `requestContainer` parameter from `runInRequestScope()`. - [#83](https://github.com/strataljs/stratal/pull/83) [`bcb3556`](https://github.com/strataljs/stratal/commit/bcb3556a6e1f185e088286f202c605c73799e63f) Thanks [@adesege](https://github.com/adesege)! - Introduce @stratal/zenstack-plugin and rearchitect database module to use shared schema with per-connection slicing @@ -751,7 +748,6 @@ ### New Package **`@stratal/zenstack-plugin`** - - ZenStack plugin for multi-connection database support with schema slicing - Generates connection-specific schema types and `StratalDatabase` augmentation - CLI commands: `stratal-db migrate` and `stratal-db push` for per-connection database management @@ -760,11 +756,9 @@ ### Breaking Changes **`stratal` (core)** - - Re-exports `delay` from tsyringe via `stratal/di` **`@stratal/framework`** - - **Replaced `DatabaseSchemaRegistry` and `DefaultDatabaseConnection` with unified `StratalDatabase` interface** — Consumers must update their type augmentations to use the new single interface with `schema`, `defaultConnection`, and `slicing` properties. - **`schema` moved from `DatabaseConnectionConfig` to `DatabaseModuleConfig`** — All connections now share a single schema; per-connection schema is no longer supported. - **Added `slicing` option to `DatabaseConnectionConfig`** — Connections can narrow available models via ZenStack slicing options. @@ -774,7 +768,6 @@ - **Removed `InferConnectionSchema` type** — Replaced by `InferDatabaseSchema` (shared) and `InferConnectionSlicing` (per-connection slicing). **`@stratal/testing`** - - **`TestingModule.getDb()` is now synchronous** — Returns `DatabaseService` directly instead of `Promise`. - **`TestingModule` creates a single request-scoped container at construction** — `container` property now returns the request-scoped container. The `runInRequestScope` pattern is removed. - **`TestingModule.close()` now disposes the request container** before shutting down the application. @@ -883,24 +876,20 @@ - Initial release of the Stratal framework — a modular Cloudflare Workers framework built on Hono and tsyringe. **Core Infrastructure** - - NestJS-style module system with `@Module()` decorator, dynamic modules (`withRoot`, `withRootAsync`), and lifecycle hooks (`OnInitialize`, `OnShutdown`) - Two-tier dependency injection container (global singletons + request-scoped) powered by tsyringe with conditional registration and service decoration - `StratalWorker` entry point extending Cloudflare's `WorkerEntrypoint` for HTTP fetch, queue batches, and scheduled cron triggers **Routing & API** - - Hono-based routing with `@Controller()` and `@Route()` decorators, automatic controller discovery, and route guards via `@UseGuards()` - OpenAPI schema generation with `@hono/zod-openapi` and Scalar API reference integration - NestJS-like middleware configuration with route-specific application and exclusion **Background Processing** - - Queue consumerfor Cloudflare Queues with `@Consumer()` and `@QueueJob()` decorators and batch processing - Cron job scheduling via `CronManager` integrated with Cloudflare's scheduled events **Services & Integrations** - - Email module with pluggable providers (Nodemailer, Resend) and queue-based sending - Storage module with AWS S3 / Cloudflare R2 support, multipart uploads, presigned URLs, and TUS resumable uploads - Internationalization (i18n) module with locale detection, message compilation, and request-scoped translations @@ -909,7 +898,6 @@ - Structured logging with JSON and pretty formatters **Developer Experience** - - Zod-powered request/response validation with type inference - Custom `ApplicationError` class with HTTP status mapping - ESM-only with full TypeScript decorator support (`emitDecoratorMetadata`) diff --git a/packages/core/package.json b/packages/core/package.json index 344098a..38c4d4b 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 d069ec3..5c6001f 100644 --- a/packages/feature-flags/CHANGELOG.md +++ b/packages/feature-flags/CHANGELOG.md @@ -1,5 +1,18 @@ # @stratal/feature-flags +## 0.1.0 + +### 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] + - stratal@0.1.0 + - @stratal/inertia@0.1.0 + ## 0.0.27 ### Patch Changes @@ -44,7 +57,6 @@ ### Patch Changes - 13b0e8d: Add `@stratal/feature-flags` — Cloudflare Flagship feature flags via the native Worker binding API. - - `FeatureFlagModule.forRoot({ apps: [{ binding, flags }], default, context })` with a declare-once flag manifest, manifest defaults, a per-request evaluation-context resolver, and multi-app support via `FeatureFlagService.use(binding)`. - `FeatureFlagShareMiddleware` shares evaluated flags to Inertia pages as the `featureFlags` prop; register it yourself (scoped to page controllers via `router.middleware(...)` or app-wide via `router.use(...)`) so a stalled Flagship binding can't block unrelated routes. Typed `useFlag` / `useFeatureFlags` hooks on `@stratal/feature-flags/react`. No runtime dependency on `@stratal/inertia`. - `@stratal/inertia`: expose a generic `ctx.share(key, value)` macro on `RouterContext` so middleware and packages can contribute per-request shared props. @@ -53,7 +65,6 @@ - 13b0e8d: Fix correctness and security issues found in review. Queue: - - Retry the correct binding: dispatch stamps the producer binding into message metadata and failed jobs record it, so `queue:retry` re-enqueues through the Cloudflare binding instead of the queue name (which is not a valid binding key and broke retry whenever the two differed). A message with no binding metadata is logged and acked rather than stored as an unretryable job. - Honor the documented retry budget: `maxRetries` now counts retries correctly against Cloudflare's 1-based `message.attempts` (previously gave one fewer retry than configured). - Derive idempotency keys from an order-stable serialization of `type` + `payload`, so payloads that differ only in key order dedupe correctly. @@ -61,7 +72,6 @@ - Documented that delivery is at-least-once with best-effort de-duplication (not exactly-once), since the processed marker is written only after a handler succeeds and KV is eventually consistent — handlers must be idempotent. Email (SMTP): - - Upgrade STARTTLS onto the socket `startTls()` returns: the original socket is closed by the runtime, so the post-upgrade reader/writer are re-derived from the new secure socket and any pre-handshake bytes are discarded (fixes a broken `smtp://` STARTTLS path on real Workers and closes the STARTTLS plaintext-injection vector). - Refuse to send credentials over an unencrypted connection: an `smtp://` server that doesn't offer STARTTLS now fails loudly instead of leaking the password (blocks STARTTLS-stripping downgrades). Credential-free connections (e.g. local Mailpit) are unaffected. - AUTH is gated on the server's advertised mechanisms and supports both `PLAIN` and `LOGIN`; usernames are percent-decoded like passwords. @@ -69,32 +79,26 @@ - MIME builder strips CR/LF from headers, escapes/RFC 2231-encodes attachment filenames (prevents header injection), base64-encodes message bodies (fixes long-line corruption), and rejects envelope addresses containing whitespace or angle brackets (prevents `MAIL FROM`/`RCPT TO` desync). Inertia SEO: - - `titleTemplate` substitutes every `%s` and treats `$`-sequences in the title literally. - Inject head/body content via function replacements, so SEO/page content containing `$`-sequences (`$$`, `$&`, `` $` ``, `$'`) is no longer corrupted or able to splice a template placeholder back into the output. - Drop unsafe attribute names — including inline event handlers (`on*`) — from custom `meta`/`link` entries (prevents tag breakout server-side, `setAttribute` errors during client head-sync, and developer-supplied event-handler attributes). Feature flags: - - `FeatureFlagService.use()` binds the target app exactly once. Database (framework): - - The reentrant `$transaction` proxy forwards the receiver for non-transaction property access. Testing: - - `TestingModule.close()` drops the isolated per-file database even if shutdown throws; the stale-database sweep escapes LIKE metacharacters so a prefix containing `_` can't over-match. DI: - - Construct singletons against the root container so they can never capture a request-scoped dependency (which would leak one request's state across every later request); an illegal singleton→request dependency now throws loudly. - Detect circular dependencies and throw a clear error naming the cycle instead of overflowing the stack. - `tryResolve` only swallows "no provider"; a registered provider that throws while constructing now surfaces the real error instead of injecting `undefined`. - Request-cache invalidation tracks transitive constructor dependencies, so re-registering a value rebuilds cached services that depend on it through a transient intermediary. Quarry dev runtime: - - Persist every durable plugin (KV, D1, R2, Durable Objects, cache) under `.wrangler/state/v3`, matching `wrangler dev` (previously only R2 was persisted); load `.env.local` / `.env..local` into `process.env` for full parity. - The `cloudflare:sockets` STARTTLS shim re-attaches the stream error handler to the upgraded socket, so post-upgrade connection errors still surface. diff --git a/packages/feature-flags/package.json b/packages/feature-flags/package.json index 62e9a2e..3d98e75 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", @@ -59,11 +59,11 @@ "peerDependencies": { "@inertiajs/core": ">=3", "@inertiajs/react": ">=3", - "@stratal/inertia": ">=0.0.27", + "@stratal/inertia": ">=0.1.0", "hono": ">=4", "react": ">=19", "react-dom": ">=19", - "stratal": ">=0.0.27" + "stratal": ">=0.1.0" }, "peerDependenciesMeta": { "@inertiajs/core": { diff --git a/packages/framework/CHANGELOG.md b/packages/framework/CHANGELOG.md index 7c5395d..2feb218 100644 --- a/packages/framework/CHANGELOG.md +++ b/packages/framework/CHANGELOG.md @@ -1,5 +1,64 @@ # @stratal/framework +## 0.1.0 + +### 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. + + ```typescript + 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 ``, ``, `` 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 `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: + + ```typescript + 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 + +- Updated dependencies [a753e55] + - stratal@0.1.0 + ## 0.0.27 ### Patch Changes @@ -14,7 +73,6 @@ - ab95f52: Close database connections on application shutdown ### Details - - Database clients now disconnect their underlying pools when the application shuts down (including dev-server hot reloads), instead of leaking connections until the process exits - Database clients also implement the async-disposal contract (`Symbol.asyncDispose`), so they participate in container disposal @@ -29,7 +87,6 @@ - e93db60: Emit entity mutation events with full entity snapshots from the database layer ### Details - - New typed events: `entity.{Model}.created` (`{ after }`), `entity.{Model}.updated` (`{ before, after }`), and `entity.{Model}.deleted` (`{ before }`), plus wildcard subscriptions (`entity.{Model}`, `entity.{verb}`, `entity`). - Unlike the existing `before.*`/`after.*` events (raw query args/result), entity events carry full entity snapshots, with the pre-mutation snapshot loaded inside the mutation's transaction. - Listener-driven cost: snapshots are only loaded when a matching subscription exists, so models nobody observes pay nothing. A global `entity` wildcard makes every model pay the pre-read — subscribe per model when cost matters. @@ -48,7 +105,6 @@ ### Patch Changes - 13b0e8d: Add `@stratal/feature-flags` — Cloudflare Flagship feature flags via the native Worker binding API. - - `FeatureFlagModule.forRoot({ apps: [{ binding, flags }], default, context })` with a declare-once flag manifest, manifest defaults, a per-request evaluation-context resolver, and multi-app support via `FeatureFlagService.use(binding)`. - `FeatureFlagShareMiddleware` shares evaluated flags to Inertia pages as the `featureFlags` prop; register it yourself (scoped to page controllers via `router.middleware(...)` or app-wide via `router.use(...)`) so a stalled Flagship binding can't block unrelated routes. Typed `useFlag` / `useFeatureFlags` hooks on `@stratal/feature-flags/react`. No runtime dependency on `@stratal/inertia`. - `@stratal/inertia`: expose a generic `ctx.share(key, value)` macro on `RouterContext` so middleware and packages can contribute per-request shared props. @@ -57,7 +113,6 @@ - 13b0e8d: Fix correctness and security issues found in review. Queue: - - Retry the correct binding: dispatch stamps the producer binding into message metadata and failed jobs record it, so `queue:retry` re-enqueues through the Cloudflare binding instead of the queue name (which is not a valid binding key and broke retry whenever the two differed). A message with no binding metadata is logged and acked rather than stored as an unretryable job. - Honor the documented retry budget: `maxRetries` now counts retries correctly against Cloudflare's 1-based `message.attempts` (previously gave one fewer retry than configured). - Derive idempotency keys from an order-stable serialization of `type` + `payload`, so payloads that differ only in key order dedupe correctly. @@ -65,7 +120,6 @@ - Documented that delivery is at-least-once with best-effort de-duplication (not exactly-once), since the processed marker is written only after a handler succeeds and KV is eventually consistent — handlers must be idempotent. Email (SMTP): - - Upgrade STARTTLS onto the socket `startTls()` returns: the original socket is closed by the runtime, so the post-upgrade reader/writer are re-derived from the new secure socket and any pre-handshake bytes are discarded (fixes a broken `smtp://` STARTTLS path on real Workers and closes the STARTTLS plaintext-injection vector). - Refuse to send credentials over an unencrypted connection: an `smtp://` server that doesn't offer STARTTLS now fails loudly instead of leaking the password (blocks STARTTLS-stripping downgrades). Credential-free connections (e.g. local Mailpit) are unaffected. - AUTH is gated on the server's advertised mechanisms and supports both `PLAIN` and `LOGIN`; usernames are percent-decoded like passwords. @@ -73,41 +127,33 @@ - MIME builder strips CR/LF from headers, escapes/RFC 2231-encodes attachment filenames (prevents header injection), base64-encodes message bodies (fixes long-line corruption), and rejects envelope addresses containing whitespace or angle brackets (prevents `MAIL FROM`/`RCPT TO` desync). Inertia SEO: - - `titleTemplate` substitutes every `%s` and treats `$`-sequences in the title literally. - Inject head/body content via function replacements, so SEO/page content containing `$`-sequences (`$$`, `$&`, `` $` ``, `$'`) is no longer corrupted or able to splice a template placeholder back into the output. - Drop unsafe attribute names — including inline event handlers (`on*`) — from custom `meta`/`link` entries (prevents tag breakout server-side, `setAttribute` errors during client head-sync, and developer-supplied event-handler attributes). Feature flags: - - `FeatureFlagService.use()` binds the target app exactly once. Database (framework): - - The reentrant `$transaction` proxy forwards the receiver for non-transaction property access. Testing: - - `TestingModule.close()` drops the isolated per-file database even if shutdown throws; the stale-database sweep escapes LIKE metacharacters so a prefix containing `_` can't over-match. DI: - - Construct singletons against the root container so they can never capture a request-scoped dependency (which would leak one request's state across every later request); an illegal singleton→request dependency now throws loudly. - Detect circular dependencies and throw a clear error naming the cycle instead of overflowing the stack. - `tryResolve` only swallows "no provider"; a registered provider that throws while constructing now surfaces the real error instead of injecting `undefined`. - Request-cache invalidation tracks transitive constructor dependencies, so re-registering a value rebuilds cached services that depend on it through a transient intermediary. Quarry dev runtime: - - Persist every durable plugin (KV, D1, R2, Durable Objects, cache) under `.wrangler/state/v3`, matching `wrangler dev` (previously only R2 was persisted); load `.env.local` / `.env..local` into `process.env` for full parity. - The `cloudflare:sockets` STARTTLS shim re-attaches the stream error handler to the upgraded socket, so post-upgrade connection errors still surface. - 13b0e8d: Make database transactions reentrant and remove `AuthContextMiddleware` - - Nested `$transaction` calls now reuse the active transaction instead of acquiring a second connection, fixing deadlocks on single-connection pools (e.g. Hyperdrive with `max: 1`) when libraries such as Better Auth run nested transactions. ### Breaking Changes - - **`AuthContextMiddleware` is removed.** Auth context is now registered automatically per request. If you registered this middleware explicitly, remove the registration — `SessionVerificationMiddleware` is sufficient. - Updated dependencies [13b0e8d] @@ -129,13 +175,11 @@ - 1658945: Migrate all error classes to `HttpException`, move heavy dependencies to peer dependencies ### Breaking Changes - - **Error classes migrated** — All framework error classes (`InsufficientPermissionsError`, auth errors, database errors, context errors) now extend `HttpException` instead of `ApplicationError`. Constructor signatures are simplified — remove `i18nKey` and `code` arguments. - **`@better-auth/core`, `@zenstackhq/orm`, `@zenstackhq/schema`, and `better-auth` moved to peer dependencies** — Install them directly in your application if not already present. - **Database error mapping simplified** — `fromZenStackError()` no longer maps to typed error code objects. It returns plain `HttpException` instances with descriptive messages. - 4b273ea: Adapt to the new built-in DI container from `stratal`, removing all `tsyringe` and `reflect-metadata` usage - - All request-scoped services now use the `@Request` decorator instead of `@Transient`. - `DatabaseModule` uses `lazy()` for dynamic connection registration instead of tsyringe's `delay()`. - `reflect-metadata` is no longer required as a peer dependency. @@ -163,7 +207,6 @@ - f8c61e1: Auto-wire Better Auth's rate limiting through Stratal's `RateLimiterModule` When `RateLimiterModule` is imported alongside `AuthModule`, Better Auth's `rateLimit` block is configured automatically: - - `customStorage` is backed by Stratal's shared `IRateLimiterStore`, so HTTP throttling and Better Auth share one store. - `customRules` is populated from a new `RateLimiterRegistry.forPath(path, resolver)` API, letting apps declare path-keyed limits (e.g. `/sign-in/email`, `/two-factor/*`) using the same `Limit` builder used elsewhere. - User-supplied `rateLimit.customStorage` and `rateLimit.customRules` keys take precedence on a per-key basis. @@ -180,13 +223,11 @@ `AuthContext` now holds the full user record returned by Better Auth's `getSession()` instead of just `userId`/`role`, so controllers and services can read profile fields without re-querying the database. ### Breaking Changes - - `AuthInfo` shape changed from `{ userId?, role? }` to `{ user: AuthUser }`. `setAuthContext({ userId, role })` callers must pass `setAuthContext({ user })` instead. - `getAuthContext()` was renamed to `getAuthInfo()` and now returns `{ user }`. - `AuthContext.getRole()` reads from `user.role`. Apps that use roles should augment the new `AuthUser` interface with `role: string` (or your app's role field) so it stays typed. ### New API - - `AuthUser` interface (extends Better Auth's `BaseUser` with optional `name`) is augmentable via `declare module '@stratal/framework/context'` for app-specific fields. - `AuthContext.getUser()` returns the user or `undefined`. - `AuthContext.requireUser()` returns the user or throws `UserNotAuthenticatedError`. @@ -228,19 +269,16 @@ - 3b16f5b: Replace Casbin-based RBAC module with Better Auth access control module ### Breaking Changes - - The `RbacModule`, `CasbinService`, `CasbinEnforcerService`, and all Casbin-related exports under `@stratal/framework/rbac` have been removed. - Use the new `@stratal/framework/access-control` module instead, which integrates with Better Auth's built-in access control system. - `AuthGuard` now uses `AccessService` instead of `CasbinService` for permission checks. ### Migration - 1. Replace `RbacModule` imports with the new access control setup via `createAccessControl()`. 2. Define resources and roles using `createAccessControl({ resources, roles })` and pass the result to `AuthModule.forRootAsync()`. 3. Replace `CasbinService` usage with `AccessService` from `@stratal/framework/access-control`. - 3b16f5b: Add organization-related error handling and internationalization support for auth module - - Add structured error codes and i18n messages for organization operations (not found, member not found, invitation errors, limit reached). - Enhance Better Auth error handler to map organization-specific errors to appropriate HTTP responses. @@ -249,7 +287,6 @@ **Why:** Multiple modules augmenting `AppMessages` with a shared top-level parent (e.g., `errors.auth`, `errors.uploads`, `errors.branding`) collided with TypeScript error **TS2717** ("Subsequent property declarations must have the same type"). Interface merging adds new properties across declarations but requires same-named properties to have structurally identical types — it does not deep-merge nested shapes. **What changed:** - - Replaced the single augmentable `AppMessages` interface with an `AppMessageNamespaces` keyed registry. Each module declares its own distinct top-level key (Laravel-style package namespacing). Because each declaration adds a different property, interface merging accepts them all. - `AppMessages` is now derived: `{ [K in keyof AppMessageNamespaces]: AppMessageNamespaces[K] }`. - Access keys are unchanged dot-notation — `i18n.t('auth.errors.invalidCredentials')` — so no custom resolver is needed. @@ -277,7 +314,6 @@ ``` **Framework package moves:** - - All `errors.auth.*` keys (previously split between `stratal` core and `@stratal/framework`) now live in the auth module as `auth.errors.*`. `errors.auth.org.*` → `auth.org.*`. The `errors.auth.*` namespace has been removed from `stratal`'s core messages. - `@stratal/framework`'s `DatabaseModule` now registers its `database.*` validation messages via `I18nModule.registerMessages` (previously the messages file existed but was never wired up). - `@stratal/inertia-modal`'s `errors.modal.*` key moved to `modal.errors.*`. @@ -312,7 +348,6 @@ - c9176ea: Migrate auth middleware to router-scoped configuration and improve error resilience ### Details - - Migrate `AuthModule` from `MiddlewareConfigurable` to `RouteConfigurable` interface - Add graceful error handling in session verification to prevent invalidated sessions from blocking requests - Expand Better Auth error mapping for token expiry, signup, and session creation failures @@ -331,7 +366,6 @@ - [`cbfce8b`](https://github.com/strataljs/stratal/commit/cbfce8b3a3517b60d94f500c5dc1ef68d8ee76f4) Thanks [@adesege](https://github.com/adesege)! - Export database CLI commands from `@stratal/framework/database` ### Details - - Export `ZenStackCommand`, `DbGenerateCommand`, `DbPullCommand`, `DbPushCommand`, `MigrateDeployCommand`, `MigrateDevCommand`, `MigrateResetCommand`, and `MigrateStatusCommand` - [#147](https://github.com/strataljs/stratal/pull/147) [`7f2772b`](https://github.com/strataljs/stratal/commit/7f2772ba90a9b6a91603f79293d384e972864125) Thanks [@adesege](https://github.com/adesege)! - Fix database event types to correctly resolve models and operation args across multiple schema connections using distributive conditional types @@ -346,7 +380,6 @@ - [#142](https://github.com/strataljs/stratal/pull/142) [`4b958e2`](https://github.com/strataljs/stratal/commit/4b958e250c99681a99a34a398fbf706546f556cc) Thanks [@adesege](https://github.com/adesege)! - Move auth, database, RBAC, and factory dependencies from optional peer dependencies to hard dependencies ### Details - - `@better-auth/core`, `better-auth`, `@faker-js/faker`, `@zenstackhq/cli`, `@zenstackhq/orm`, and `casbin` are now direct dependencies - Remove `peerDependenciesMeta` optional markers for these packages @@ -385,9 +418,7 @@ - [#97](https://github.com/strataljs/stratal/pull/97) [`d58b878`](https://github.com/strataljs/stratal/commit/d58b8782848562a50b79cd558eaf01978aa77f26) Thanks [@adesege](https://github.com/adesege)! - Add `stratalTest()` vitest plugin and migrate fetch mocking from Cloudflare's undici-based `fetchMock` to MSW ### Details - - **@stratal/testing** - - Add `@stratal/testing/vitest-plugin` sub-export with `stratalTest()` — wraps `cloudflareTest` with Stratal defaults (tslib alias, ZenStack mocks, SSR externals) - Replace `FetchMock`/`createFetchMock` with `MockFetch`/`createMockFetch` backed by MSW (`setupServer`) - Re-export `http` and `HttpResponse` from `msw` for convenience @@ -395,7 +426,6 @@ - Bump vitest peer dependency from `^3.2.0` to `^4.1.0` - **stratal** - - Update test mocks to use class syntax for Vitest 4 compatibility - Bump dependencies: `@intlify/*`, `@scalar/hono-api-reference`, `hono`, `@aws-sdk/*`, `vitest` @@ -404,7 +434,6 @@ - Bump dependencies: `better-auth`, `@zenstackhq/*`, `wrangler`, `vitest` ### Breaking Changes - - **@stratal/testing**: `FetchMock` and `createFetchMock` are removed. Use `MockFetch`/`createMockFetch` instead. The new API uses MSW lifecycle methods (`listen`/`reset`/`close`) instead of `activate`/`disableNetConnect`/`deactivate`. - **@stratal/testing**: Vitest peer dependency is now `^4.1.0` (was `^3.2.0`). @@ -420,7 +449,6 @@ ### Breaking Changes **@stratal/framework** - - `DatabaseModuleConfig` no longer accepts a top-level `schema` property. Each connection in `connections` now requires its own `schema` property. - `DatabaseConnectionConfig` no longer accepts `slicing`. Each connection defines its own schema, making slicing unnecessary. - `StratalDatabase` augmentation interface changed: replace `schema` and `slicing` with `schemas` (a map of connection name to schema type). @@ -477,7 +505,6 @@ - [#84](https://github.com/strataljs/stratal/pull/84) [`3b38b81`](https://github.com/strataljs/stratal/commit/3b38b8184428dc0f79ffbe9dc55ba782d46dea03) Thanks [@adesege](https://github.com/adesege)! - Rename `AuthModule.withRootAsync` to `AuthModule.forRootAsync` for consistency with core framework naming conventions ### Breaking Changes - - **@stratal/framework**: `AuthModule.withRootAsync()` has been renamed to `AuthModule.forRootAsync()`. Update all usages: ```diff - AuthModule.withRootAsync({ ... }) @@ -496,7 +523,6 @@ ### Breaking Changes **`stratal` (core)** - - **Removed `RequestContextStore`** — The `AsyncLocalStorage`-based request context propagation is eliminated. This removes the dependency on the `nodejs_als` compatibility flag in Cloudflare Workers. - **Removed `RouterService` and `RequestScopeService`** — Replaced by `HonoApp`, a subclass of `OpenAPIHono` that directly integrates request scoping, middleware class support, and global error handling. - **Removed `RouterAlreadyConfiguredError` and `RouterNotConfiguredError`** — Replaced by `HonoAppAlreadyConfiguredError`. @@ -508,12 +534,10 @@ - **New `HonoApp` class** — Extends `OpenAPIHono` with Stratal concerns; supports `Constructor` in `use()` via module augmentation. **`@stratal/framework`** - - **`DatabaseConnectionConfig.dialect` changed from `Dialect` to `() => Dialect`** — Database connections now take a factory function for lazy dialect/pool creation. - **Caching strategy changed from `instancePerContainerCachingFactory` to `instanceCachingFactory`**. **`@stratal/testing`** - - **`TestingModule.runInRequestScope()` callback now receives a `container` parameter** — Update all callbacks to use the passed container for service resolution. - **`TestingModule.fetch()` now routes through `HonoApp`** instead of `RouterService`. - **`TestingModuleBuilder.compile()` now applies overrides before `initialize()`** — Fixes issue where overrides were applied after initialization. @@ -521,7 +545,6 @@ ### Minor Changes **`@stratal/seeders`** - - Updated `executeSeeder()` to use the explicit `requestContainer` parameter from `runInRequestScope()`. - [#83](https://github.com/strataljs/stratal/pull/83) [`bcb3556`](https://github.com/strataljs/stratal/commit/bcb3556a6e1f185e088286f202c605c73799e63f) Thanks [@adesege](https://github.com/adesege)! - Introduce @stratal/zenstack-plugin and rearchitect database module to use shared schema with per-connection slicing @@ -529,7 +552,6 @@ ### New Package **`@stratal/zenstack-plugin`** - - ZenStack plugin for multi-connection database support with schema slicing - Generates connection-specific schema types and `StratalDatabase` augmentation - CLI commands: `stratal-db migrate` and `stratal-db push` for per-connection database management @@ -538,11 +560,9 @@ ### Breaking Changes **`stratal` (core)** - - Re-exports `delay` from tsyringe via `stratal/di` **`@stratal/framework`** - - **Replaced `DatabaseSchemaRegistry` and `DefaultDatabaseConnection` with unified `StratalDatabase` interface** — Consumers must update their type augmentations to use the new single interface with `schema`, `defaultConnection`, and `slicing` properties. - **`schema` moved from `DatabaseConnectionConfig` to `DatabaseModuleConfig`** — All connections now share a single schema; per-connection schema is no longer supported. - **Added `slicing` option to `DatabaseConnectionConfig`** — Connections can narrow available models via ZenStack slicing options. @@ -552,7 +572,6 @@ - **Removed `InferConnectionSchema` type** — Replaced by `InferDatabaseSchema` (shared) and `InferConnectionSlicing` (per-connection slicing). **`@stratal/testing`** - - **`TestingModule.getDb()` is now synchronous** — Returns `DatabaseService` directly instead of `Promise`. - **`TestingModule` creates a single request-scoped container at construction** — `container` property now returns the request-scoped container. The `runInRequestScope` pattern is removed. - **`TestingModule.close()` now disposes the request container** before shutting down the application. diff --git a/packages/framework/package.json b/packages/framework/package.json index e3b7d33..49e354d 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": { @@ -80,7 +80,7 @@ "@zenstackhq/schema": ">=3.6", "better-auth": ">=1.6", "pg": "^8.0.0", - "stratal": ">=0.0.27" + "stratal": ">=0.1.0" }, "devDependencies": { "@better-auth/core": "^1.7.5", diff --git a/packages/inertia-modal/CHANGELOG.md b/packages/inertia-modal/CHANGELOG.md index bc73ab9..42da1eb 100644 --- a/packages/inertia-modal/CHANGELOG.md +++ b/packages/inertia-modal/CHANGELOG.md @@ -1,5 +1,122 @@ # @stratal/inertia-modal +## 0.1.0 + +### 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. + - `` 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. + + ```typescript + return ctx.modal( + "Parent/Index", + { + items: ctx.scroll( + () => + db.$cursor.item.findMany({ + cursor, + take: 20, + orderBy: [{ updatedAt: "desc" }, { id: "desc" }], + }), + { matchOn: "id" }, + ), + }, + { base: "/parent" }, + ); + ``` + + - `` and `` 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.** `` 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. + + ```tsx + createInertiaApp({ + resolve: withModals((name) => pages[`./pages/${name}.tsx`]()), + setup: ({ el, App, props }) => hydrateRoot(el, ), + }); + ``` + + - 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: + + ```typescript + 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(depth?)` and `modalLevels()`. + - `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 `` 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 `` 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 `` with ``, 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.` 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: + + ```diff + -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 + +- Updated dependencies [a753e55] +- Updated dependencies [a753e55] +- Updated dependencies [a753e55] + - stratal@0.1.0 + - @stratal/inertia@0.1.0 + - @stratal/testing@0.1.0 + ## 0.0.27 ### Patch Changes @@ -59,7 +176,6 @@ ### Patch Changes - 4b273ea: Add `nativeBack` support to modal navigation and eagerly resolve deferred props in background page fetches - - `useModal().redirect()` now uses `history.back()` instead of a server round-trip when the modal was loaded via a partial reload, providing instant close behavior. - Background page fetches send `x-inertia-resolve-deferred: true` to ensure deferred props are included in the response. @@ -76,7 +192,6 @@ ### Patch Changes - 3489cfd: Preserve query string and forwarded headers on modal background requests - - The background page request now keeps the referer URL's query string, so opening a modal no longer resets the parent list view's filter/pagination state to defaults. - `x-forwarded-proto`, `x-forwarded-host`, `x-forwarded-for`, `x-forwarded-port`, `x-real-ip`, `accept-language`, and `user-agent` are forwarded from the original request when present. Middleware that reconstructs the canonical request URL (e.g. apps whose `appUrl` is derived from forwarded headers) now sees the same protocol/host as the original request, fixing background fetches that previously appeared unauthenticated because Better Auth's secure-cookie prefix was resolved against the wrong base URL. @@ -116,7 +231,6 @@ **Why:** Multiple modules augmenting `AppMessages` with a shared top-level parent (e.g., `errors.auth`, `errors.uploads`, `errors.branding`) collided with TypeScript error **TS2717** ("Subsequent property declarations must have the same type"). Interface merging adds new properties across declarations but requires same-named properties to have structurally identical types — it does not deep-merge nested shapes. **What changed:** - - Replaced the single augmentable `AppMessages` interface with an `AppMessageNamespaces` keyed registry. Each module declares its own distinct top-level key (Laravel-style package namespacing). Because each declaration adds a different property, interface merging accepts them all. - `AppMessages` is now derived: `{ [K in keyof AppMessageNamespaces]: AppMessageNamespaces[K] }`. - Access keys are unchanged dot-notation — `i18n.t('auth.errors.invalidCredentials')` — so no custom resolver is needed. @@ -144,7 +258,6 @@ ``` **Framework package moves:** - - All `errors.auth.*` keys (previously split between `stratal` core and `@stratal/framework`) now live in the auth module as `auth.errors.*`. `errors.auth.org.*` → `auth.org.*`. The `errors.auth.*` namespace has been removed from `stratal`'s core messages. - `@stratal/framework`'s `DatabaseModule` now registers its `database.*` validation messages via `I18nModule.registerMessages` (previously the messages file existed but was never wired up). - `@stratal/inertia-modal`'s `errors.modal.*` key moved to `modal.errors.*`. diff --git a/packages/inertia-modal/package.json b/packages/inertia-modal/package.json index e6b7e0d..dac4126 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", "sideEffects": [ @@ -55,11 +55,11 @@ "peerDependencies": { "@inertiajs/core": ">=3", "@inertiajs/react": ">=3", - "@stratal/inertia": ">=0.0.27", - "@stratal/testing": ">=0.0.27", + "@stratal/inertia": ">=0.1.0", + "@stratal/testing": ">=0.1.0", "hono": ">=4", "react": ">=19", - "stratal": ">=0.0.27", + "stratal": ">=0.1.0", "vitest": ">=4" }, "peerDependenciesMeta": { diff --git a/packages/inertia/CHANGELOG.md b/packages/inertia/CHANGELOG.md index c14d051..64ee723 100644 --- a/packages/inertia/CHANGELOG.md +++ b/packages/inertia/CHANGELOG.md @@ -1,5 +1,80 @@ # @stratal/inertia +## 0.1.0 + +### 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. + + ```typescript + 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 ``, ``, `` 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. + + ### Infinite scroll + + Add **`ctx.scroll(callback, options?)`** for Inertia v3 infinite scroll, which makes `@inertiajs/react`'s `` work against a Stratal route. Until now the page carried no scroll metadata at all and the component threw before rendering. + + ```typescript + 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=`. 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 + +- Updated dependencies [a753e55] +- Updated dependencies [a753e55] + - stratal@0.1.0 + - @stratal/testing@0.1.0 + ## 0.0.27 ### Patch Changes @@ -15,13 +90,11 @@ - ab95f52: Fix flash cookie encoding crashing on non-Latin1 characters ### Details - - Flash cookies are now encoded with UTF-8-safe base64 — `btoa` alone threw on any character outside Latin1 (em-dashes, smart quotes, non-Latin scripts), which are routine in user-facing flash messages - bb6d3b9: Trailing-slash exclusions: `trailingSlash` accepts `{ mode, exclude }` ### Details - - `trailingSlash` application config now accepts `{ mode, exclude }` alongside a bare mode. Excluded paths are never redirected (308) and never rewritten by URL generation — for routes whose canonical form is owned externally (e.g. OAuth redirect URIs matched byte-for-byte). - String patterns are segment-aware prefixes; RegExp patterns match both slash forms of the pathname regardless of anchoring. - Exclusions match in route space: with path-based locale detection, a leading locale segment is stripped before matching, so `'/callback'` also exempts `/fr/callback` — in the redirect middleware, `Uri` helpers, and hreflang link generation. @@ -59,7 +132,6 @@ Also fixes `ssr.disabled` glob matching, which previously compared against the full URL and so missed routes carrying a query string (e.g. `admin/*` vs `/admin/dashboard?tab=users`); it now matches the pathname only. Rerunning `quarry inertia:install` on an existing install now wires the SSR bundle into the current `InertiaModule.forRoot({ … })` instead of leaving SSR silently disabled. ### Breaking Changes - - The SSR bundle now returns a stream, and there is no longer a silent client-side fallback — if SSR fails to load or render, the error surfaces (500) instead of degrading silently. - Migrate your `src/inertia/ssr.tsx` to use the new helper: @@ -86,7 +158,6 @@ ### Patch Changes - 13b0e8d: Add `@stratal/feature-flags` — Cloudflare Flagship feature flags via the native Worker binding API. - - `FeatureFlagModule.forRoot({ apps: [{ binding, flags }], default, context })` with a declare-once flag manifest, manifest defaults, a per-request evaluation-context resolver, and multi-app support via `FeatureFlagService.use(binding)`. - `FeatureFlagShareMiddleware` shares evaluated flags to Inertia pages as the `featureFlags` prop; register it yourself (scoped to page controllers via `router.middleware(...)` or app-wide via `router.use(...)`) so a stalled Flagship binding can't block unrelated routes. Typed `useFlag` / `useFeatureFlags` hooks on `@stratal/feature-flags/react`. No runtime dependency on `@stratal/inertia`. - `@stratal/inertia`: expose a generic `ctx.share(key, value)` macro on `RouterContext` so middleware and packages can contribute per-request shared props. @@ -95,7 +166,6 @@ - 13b0e8d: Fix correctness and security issues found in review. Queue: - - Retry the correct binding: dispatch stamps the producer binding into message metadata and failed jobs record it, so `queue:retry` re-enqueues through the Cloudflare binding instead of the queue name (which is not a valid binding key and broke retry whenever the two differed). A message with no binding metadata is logged and acked rather than stored as an unretryable job. - Honor the documented retry budget: `maxRetries` now counts retries correctly against Cloudflare's 1-based `message.attempts` (previously gave one fewer retry than configured). - Derive idempotency keys from an order-stable serialization of `type` + `payload`, so payloads that differ only in key order dedupe correctly. @@ -103,7 +173,6 @@ - Documented that delivery is at-least-once with best-effort de-duplication (not exactly-once), since the processed marker is written only after a handler succeeds and KV is eventually consistent — handlers must be idempotent. Email (SMTP): - - Upgrade STARTTLS onto the socket `startTls()` returns: the original socket is closed by the runtime, so the post-upgrade reader/writer are re-derived from the new secure socket and any pre-handshake bytes are discarded (fixes a broken `smtp://` STARTTLS path on real Workers and closes the STARTTLS plaintext-injection vector). - Refuse to send credentials over an unencrypted connection: an `smtp://` server that doesn't offer STARTTLS now fails loudly instead of leaking the password (blocks STARTTLS-stripping downgrades). Credential-free connections (e.g. local Mailpit) are unaffected. - AUTH is gated on the server's advertised mechanisms and supports both `PLAIN` and `LOGIN`; usernames are percent-decoded like passwords. @@ -111,37 +180,30 @@ - MIME builder strips CR/LF from headers, escapes/RFC 2231-encodes attachment filenames (prevents header injection), base64-encodes message bodies (fixes long-line corruption), and rejects envelope addresses containing whitespace or angle brackets (prevents `MAIL FROM`/`RCPT TO` desync). Inertia SEO: - - `titleTemplate` substitutes every `%s` and treats `$`-sequences in the title literally. - Inject head/body content via function replacements, so SEO/page content containing `$`-sequences (`$$`, `$&`, `` $` ``, `$'`) is no longer corrupted or able to splice a template placeholder back into the output. - Drop unsafe attribute names — including inline event handlers (`on*`) — from custom `meta`/`link` entries (prevents tag breakout server-side, `setAttribute` errors during client head-sync, and developer-supplied event-handler attributes). Feature flags: - - `FeatureFlagService.use()` binds the target app exactly once. Database (framework): - - The reentrant `$transaction` proxy forwards the receiver for non-transaction property access. Testing: - - `TestingModule.close()` drops the isolated per-file database even if shutdown throws; the stale-database sweep escapes LIKE metacharacters so a prefix containing `_` can't over-match. DI: - - Construct singletons against the root container so they can never capture a request-scoped dependency (which would leak one request's state across every later request); an illegal singleton→request dependency now throws loudly. - Detect circular dependencies and throw a clear error naming the cycle instead of overflowing the stack. - `tryResolve` only swallows "no provider"; a registered provider that throws while constructing now surfaces the real error instead of injecting `undefined`. - Request-cache invalidation tracks transitive constructor dependencies, so re-registering a value rebuilds cached services that depend on it through a transient intermediary. Quarry dev runtime: - - Persist every durable plugin (KV, D1, R2, Durable Objects, cache) under `.wrangler/state/v3`, matching `wrangler dev` (previously only R2 was persisted); load `.env.local` / `.env..local` into `process.env` for full parity. - The `cloudflare:sockets` STARTTLS shim re-attaches the stream error handler to the upgraded socket, so post-upgrade connection errors still surface. - 13b0e8d: Add backend-driven SEO metadata management with hreflang and automatic client-side head synchronization - - Configure app-wide SEO defaults and a title template via `InertiaModule.forRoot({ seo: { ... } })`. - Set per-page metadata from controllers or middleware with `ctx.seo({ ... })` — title, description, Open Graph, Twitter card, canonical URL, and arbitrary meta/link tags. - Locale alternates (`rel="alternate" hreflang="…"`) are generated automatically for path-prefixed and querystring locale strategies and merged into the rendered tags. @@ -171,14 +233,12 @@ ### Patch Changes - 1658945: Add `createClientViteConfig` helper, client manifest injection, sourcemap option, and `InertiaQuarryModule` for CLI integration - - New `createClientViteConfig()` produces a ready-made Vite config for the client bundle with automatic reflect-metadata invocation for tsyringe compatibility. - Inertia build command now injects the client manifest into the SSR bundle for asset resolution. - Type generator enhanced to extract controller page prop types with promise unwrapping. - New `@stratal/inertia/quarry` export provides `InertiaQuarryModule` for registering Inertia CLI commands. - 4b273ea: Replace @intlify/core-base with intl-messageformat in `useI18n` hook, add eager deferred prop resolution, and remove tsyringe/reflect-metadata dependencies - - `useI18n()` now uses `intl-messageformat` for ICU message formatting. The hook API is unchanged. - New `x-inertia-resolve-deferred` request header causes all deferred props to be resolved eagerly in the response, skipping client-side lazy loading. - The `invokeReflectMetadataBeforeTsyringeCheck` Vite plugin is removed (no longer needed). @@ -200,7 +260,6 @@ `stratalInertia()` now adds the React ecosystem (`react`, `react-dom`, `react-is`, `scheduler`, `use-sync-external-store`) and `@inertiajs/core` / `@inertiajs/react` to `resolve.dedupe` and `resolve.noExternal`. React 19's main entry is CJS and must run through the optimizer, but when Vite re-runs optimization after auto-discovering a new dep it would mint a second `?v=` copy, breaking React identity (`Invalid hook call`, dispatcher mismatch). Forcing a single physical copy through `dedupe`/`noExternal` keeps hooks, contexts, and Inertia internals working across re-optimizations. - 3489cfd: Run Inertia type generation in a worker thread and cache dev CSS per HMR cycle - - The Vite types plugin now offloads `runTypeGeneration` to a debounced (250ms) worker via `node:worker_threads`, so HMR no longer blocks on ts-morph parsing. A second edit while a worker is in flight queues exactly one follow-up run, and the dispatcher is torn down on `closeBundle`. - `writeInertiaTypes` skips the write when the on-disk content already matches and otherwise writes via a temp-file rename, so the file is never observed half-written. - `stratalInertiaDevCss` caches the collected SSR CSS and invalidates it on CSS-module HMR, eliminating duplicate scans when the SSR endpoint and the virtual module are both requested. @@ -219,7 +278,6 @@ - f8c61e1: Expose the matched route on `useRoute()` and apply trailing-slash + sticky params The `routes` Inertia shared prop now also carries a `route` snapshot for the current request (`{ name, params, defaults }`) and the application's `trailingSlash` mode, enabling several `useRoute()` enhancements: - - `currentRoute` is returned alongside `route` and `current`, so components can read the matched route name and params directly (e.g. `currentRoute.params.id`). - `current(name)` now accepts dotted wildcard patterns derived from real route names (e.g. `current('users.*')`), strictly typed against `StratalRouteMap`. - `route(name, params)` merges sticky defaults from `Uri.defaults()` and any current-route params declared by the target route, so values like `tenantId` carry over without the caller passing them. Explicit params still win. @@ -274,7 +332,6 @@ - c9176ea: Add precognition support, i18n integration, flash messages, React hooks, and testing utilities ### Details - - Add precognition middleware for form validation without full submission - Add i18n integration with automatic locale and translation sharing to Inertia pages - Add flash message support via cookie-based flash store @@ -285,7 +342,6 @@ - 17f8675: Add Inertia.js v3 server adapter for building server-driven React SPAs with Stratal ### Details - - `InertiaModule` with `forRoot()` / `forRootAsync()` configuration - `InertiaService` for rendering pages with shared data, deferred props, and partial reload support - `@InertiaRoute()` decorator for Inertia-specific controller routes diff --git a/packages/inertia/package.json b/packages/inertia/package.json index eccd1d8..0e3d8c1 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", @@ -94,11 +94,11 @@ "@inertiajs/core": ">=3", "@inertiajs/react": ">=3", "@inertiajs/vite": ">=3", - "@stratal/testing": ">=0.0.27", + "@stratal/testing": ">=0.1.0", "hono": ">=4", "react": ">=19", "react-dom": ">=19", - "stratal": ">=0.0.27", + "stratal": ">=0.1.0", "vite": ">=8", "vitest": ">=4" }, diff --git a/packages/testing/CHANGELOG.md b/packages/testing/CHANGELOG.md index 962e46f..43e444e 100644 --- a/packages/testing/CHANGELOG.md +++ b/packages/testing/CHANGELOG.md @@ -1,5 +1,46 @@ # @stratal/testing +## 0.1.0 + +### 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, `_w_`, 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 + +- Updated dependencies [a753e55] +- Updated dependencies [a753e55] + - stratal@0.1.0 + - @stratal/framework@0.1.0 + ## 0.0.27 ### Patch Changes @@ -15,7 +56,6 @@ - ab95f52: Safer-by-default test harness: rate limiting off, in-memory email, eager routing ### Details - - Rate limiting is now disabled by default (`NoopRateLimiterStore`) — suites fire many requests from one "IP" in seconds and tripped production limiter budgets (including Better Auth's built-in per-path limits). Suites testing limiter behavior must override `RATE_LIMITER_TOKENS.Store` back to a real store (e.g. `InMemoryRateLimiterStore`) - A `TestEmailProvider` is installed by default so the sync queue provider's inline `EmailConsumer` no longer opens real SMTP connections from the test worker; assert on sent messages via `module.sentEmails` - Routing is initialized eagerly during `compile()` — configuration errors (e.g. mixing `@Route()` with HTTP method decorators) now surface as a `compile()` rejection instead of on the first request, and request-scoped router services resolve regardless of test ordering @@ -51,7 +91,6 @@ - 13b0e8d: Add opt-in database isolation for parallel test execution Run test files in parallel against PostgreSQL without lock or data collisions: each file gets its own database cloned from a migrated template and dropped on teardown. - - Enable per-file isolation by passing `database: { isolation: 'database' }` to the Vitest plugin (and optionally `binding` to target a specific Hyperdrive binding, defaulting to `DB`). - New `@stratal/testing/database` entry point exposing helpers to wire up the template-database lifecycle in a Vitest `globalSetup`. - The migrated template is **reused across runs**: `createTestDatabaseGlobalSetup` fingerprints the `schema` source(s) + the `migrate` routine and stores it as the template's database COMMENT, so `migrate` runs only on the first run after a schema change (or against a fresh database) — subsequent runs clone the existing template directly. `schema` (a file or directory path, or a list) is now **required** in `database` mode. Reuse is purely fingerprint-driven — there is no force/skip flag. @@ -60,7 +99,6 @@ - 13b0e8d: Fix correctness and security issues found in review. Queue: - - Retry the correct binding: dispatch stamps the producer binding into message metadata and failed jobs record it, so `queue:retry` re-enqueues through the Cloudflare binding instead of the queue name (which is not a valid binding key and broke retry whenever the two differed). A message with no binding metadata is logged and acked rather than stored as an unretryable job. - Honor the documented retry budget: `maxRetries` now counts retries correctly against Cloudflare's 1-based `message.attempts` (previously gave one fewer retry than configured). - Derive idempotency keys from an order-stable serialization of `type` + `payload`, so payloads that differ only in key order dedupe correctly. @@ -68,7 +106,6 @@ - Documented that delivery is at-least-once with best-effort de-duplication (not exactly-once), since the processed marker is written only after a handler succeeds and KV is eventually consistent — handlers must be idempotent. Email (SMTP): - - Upgrade STARTTLS onto the socket `startTls()` returns: the original socket is closed by the runtime, so the post-upgrade reader/writer are re-derived from the new secure socket and any pre-handshake bytes are discarded (fixes a broken `smtp://` STARTTLS path on real Workers and closes the STARTTLS plaintext-injection vector). - Refuse to send credentials over an unencrypted connection: an `smtp://` server that doesn't offer STARTTLS now fails loudly instead of leaking the password (blocks STARTTLS-stripping downgrades). Credential-free connections (e.g. local Mailpit) are unaffected. - AUTH is gated on the server's advertised mechanisms and supports both `PLAIN` and `LOGIN`; usernames are percent-decoded like passwords. @@ -76,32 +113,26 @@ - MIME builder strips CR/LF from headers, escapes/RFC 2231-encodes attachment filenames (prevents header injection), base64-encodes message bodies (fixes long-line corruption), and rejects envelope addresses containing whitespace or angle brackets (prevents `MAIL FROM`/`RCPT TO` desync). Inertia SEO: - - `titleTemplate` substitutes every `%s` and treats `$`-sequences in the title literally. - Inject head/body content via function replacements, so SEO/page content containing `$`-sequences (`$$`, `$&`, `` $` ``, `$'`) is no longer corrupted or able to splice a template placeholder back into the output. - Drop unsafe attribute names — including inline event handlers (`on*`) — from custom `meta`/`link` entries (prevents tag breakout server-side, `setAttribute` errors during client head-sync, and developer-supplied event-handler attributes). Feature flags: - - `FeatureFlagService.use()` binds the target app exactly once. Database (framework): - - The reentrant `$transaction` proxy forwards the receiver for non-transaction property access. Testing: - - `TestingModule.close()` drops the isolated per-file database even if shutdown throws; the stale-database sweep escapes LIKE metacharacters so a prefix containing `_` can't over-match. DI: - - Construct singletons against the root container so they can never capture a request-scoped dependency (which would leak one request's state across every later request); an illegal singleton→request dependency now throws loudly. - Detect circular dependencies and throw a clear error naming the cycle instead of overflowing the stack. - `tryResolve` only swallows "no provider"; a registered provider that throws while constructing now surfaces the real error instead of injecting `undefined`. - Request-cache invalidation tracks transitive constructor dependencies, so re-registering a value rebuilds cached services that depend on it through a transient intermediary. Quarry dev runtime: - - Persist every durable plugin (KV, D1, R2, Durable Objects, cache) under `.wrangler/state/v3`, matching `wrangler dev` (previously only R2 was persisted); load `.env.local` / `.env..local` into `process.env` for full parity. - The `cloudflare:sockets` STARTTLS shim re-attaches the stream error handler to the upgraded socket, so post-upgrade connection errors still surface. @@ -169,7 +200,6 @@ ### Patch Changes - 3b16f5b: Make `TestHttpClient` immutable and extend test classes with `Macroable` - - `TestHttpClient.forHost()`, `withHeaders()`, and `withLocale()` now return new instances instead of mutating `this`, preventing shared state between tests. - `TestHttpRequest` and `TestResponse` now extend `Macroable`, allowing apps to register custom assertion methods and helpers at runtime. - Add `TestingModule.inertia` getter for convenient Inertia request testing. @@ -193,7 +223,6 @@ - c9176ea: Add locale support to test HTTP client, SSE, and WebSocket requests ### Details - - Add `withLocale()` method to `TestHttpClient`, `TestHttpRequest`, `TestSseRequest`, and `TestWsRequest` - Automatically resolves locale detection strategy from the module's I18n configuration - Export `getValueAtPath` and `hasValueAtPath` path utility functions @@ -221,7 +250,6 @@ - [#142](https://github.com/strataljs/stratal/pull/142) [`4b958e2`](https://github.com/strataljs/stratal/commit/4b958e250c99681a99a34a398fbf706546f556cc) Thanks [@adesege](https://github.com/adesege)! - Add dedicated `@stratal/testing/storage` sub-path export and add `reflect-metadata` as peer dependency ### Details - - `FakeStorageService` and `StoredFile` are no longer exported from the main entry point — import from `@stratal/testing/storage` instead - Add `reflect-metadata` as a peer dependency @@ -236,7 +264,6 @@ - [#125](https://github.com/strataljs/stratal/pull/125) [`0731e99`](https://github.com/strataljs/stratal/commit/0731e99c3e0c96f988387611f0ef8559b63d7bd8) Thanks [@adesege](https://github.com/adesege)! - Add test utilities for Quarry command framework ### Details - - Add `TestCommandRequest` fluent builder for constructing command inputs in tests - Add `TestCommandResult` assertion wrapper for command output, exit codes, and errors - Add `quarry(name)` method to `TestingModule` for convenient command testing @@ -253,7 +280,6 @@ - [#124](https://github.com/strataljs/stratal/pull/124) [`59251d3`](https://github.com/strataljs/stratal/commit/59251d32743cbd461f952985f192a68cb7ccdb91) Thanks [@adesege](https://github.com/adesege)! - Add `fixPgCjs()` Vite plugin for CJS resolution of pg sub-dependencies in workerd ### Details - - Replace the `@cloudflare/vitest-pool-workers` yarn patch with a dedicated `fixPgCjs()` Vite plugin - `fixPgCjs()` must be applied at the root `defineConfig` level for the module fallback resolver to work correctly - `stratalTest()` does NOT automatically apply `fixPgCjs()` — it must be registered separately at the root level @@ -272,7 +298,6 @@ - [#120](https://github.com/strataljs/stratal/pull/120) [`8d0df50`](https://github.com/strataljs/stratal/commit/8d0df506411bc725ef4e4eaf4efdb314b3384d98) Thanks [@adesege](https://github.com/adesege)! - Add SSE testing utilities with `TestSseRequest` and `TestSseConnection` ### Details - - `TestingModule.sse(path)` creates an SSE test request builder - `TestSseRequest` supports custom headers, authentication via `actingAs()`, and automatic `Accept: text/event-stream` header - `TestSseConnection` wraps a live SSE stream with assertion helpers: `assertEvent()`, `assertEventData()`, `assertJsonEventData()`, `waitForEvent()`, `waitForEnd()`, `collectEvents()` @@ -281,7 +306,6 @@ - [#120](https://github.com/strataljs/stratal/pull/120) [`8d0df50`](https://github.com/strataljs/stratal/commit/8d0df506411bc725ef4e4eaf4efdb314b3384d98) Thanks [@adesege](https://github.com/adesege)! - Add WebSocket testing utilities with `TestWsRequest` and `TestWsConnection` ### Details - - `TestingModule.ws(path)` creates a WebSocket test request builder - `TestWsRequest` supports custom headers, authentication via `actingAs()`, and WebSocket upgrade handshake - `TestWsConnection` wraps a live WebSocket with assertion helpers: `assertMessage()`, `assertClosed()`, `waitForMessage()`, `waitForClose()` @@ -297,9 +321,7 @@ - [#97](https://github.com/strataljs/stratal/pull/97) [`d58b878`](https://github.com/strataljs/stratal/commit/d58b8782848562a50b79cd558eaf01978aa77f26) Thanks [@adesege](https://github.com/adesege)! - Add `stratalTest()` vitest plugin and migrate fetch mocking from Cloudflare's undici-based `fetchMock` to MSW ### Details - - **@stratal/testing** - - Add `@stratal/testing/vitest-plugin` sub-export with `stratalTest()` — wraps `cloudflareTest` with Stratal defaults (tslib alias, ZenStack mocks, SSR externals) - Replace `FetchMock`/`createFetchMock` with `MockFetch`/`createMockFetch` backed by MSW (`setupServer`) - Re-export `http` and `HttpResponse` from `msw` for convenience @@ -307,7 +329,6 @@ - Bump vitest peer dependency from `^3.2.0` to `^4.1.0` - **stratal** - - Update test mocks to use class syntax for Vitest 4 compatibility - Bump dependencies: `@intlify/*`, `@scalar/hono-api-reference`, `hono`, `@aws-sdk/*`, `vitest` @@ -316,7 +337,6 @@ - Bump dependencies: `better-auth`, `@zenstackhq/*`, `wrangler`, `vitest` ### Breaking Changes - - **@stratal/testing**: `FetchMock` and `createFetchMock` are removed. Use `MockFetch`/`createMockFetch` instead. The new API uses MSW lifecycle methods (`listen`/`reset`/`close`) instead of `activate`/`disableNetConnect`/`deactivate`. - **@stratal/testing**: Vitest peer dependency is now `^4.1.0` (was `^3.2.0`). @@ -365,7 +385,6 @@ ### Breaking Changes **`stratal` (core)** - - **Removed `RequestContextStore`** — The `AsyncLocalStorage`-based request context propagation is eliminated. This removes the dependency on the `nodejs_als` compatibility flag in Cloudflare Workers. - **Removed `RouterService` and `RequestScopeService`** — Replaced by `HonoApp`, a subclass of `OpenAPIHono` that directly integrates request scoping, middleware class support, and global error handling. - **Removed `RouterAlreadyConfiguredError` and `RouterNotConfiguredError`** — Replaced by `HonoAppAlreadyConfiguredError`. @@ -377,12 +396,10 @@ - **New `HonoApp` class** — Extends `OpenAPIHono` with Stratal concerns; supports `Constructor` in `use()` via module augmentation. **`@stratal/framework`** - - **`DatabaseConnectionConfig.dialect` changed from `Dialect` to `() => Dialect`** — Database connections now take a factory function for lazy dialect/pool creation. - **Caching strategy changed from `instancePerContainerCachingFactory` to `instanceCachingFactory`**. **`@stratal/testing`** - - **`TestingModule.runInRequestScope()` callback now receives a `container` parameter** — Update all callbacks to use the passed container for service resolution. - **`TestingModule.fetch()` now routes through `HonoApp`** instead of `RouterService`. - **`TestingModuleBuilder.compile()` now applies overrides before `initialize()`** — Fixes issue where overrides were applied after initialization. @@ -390,7 +407,6 @@ ### Minor Changes **`@stratal/seeders`** - - Updated `executeSeeder()` to use the explicit `requestContainer` parameter from `runInRequestScope()`. - [#83](https://github.com/strataljs/stratal/pull/83) [`bcb3556`](https://github.com/strataljs/stratal/commit/bcb3556a6e1f185e088286f202c605c73799e63f) Thanks [@adesege](https://github.com/adesege)! - Introduce @stratal/zenstack-plugin and rearchitect database module to use shared schema with per-connection slicing @@ -398,7 +414,6 @@ ### New Package **`@stratal/zenstack-plugin`** - - ZenStack plugin for multi-connection database support with schema slicing - Generates connection-specific schema types and `StratalDatabase` augmentation - CLI commands: `stratal-db migrate` and `stratal-db push` for per-connection database management @@ -407,11 +422,9 @@ ### Breaking Changes **`stratal` (core)** - - Re-exports `delay` from tsyringe via `stratal/di` **`@stratal/framework`** - - **Replaced `DatabaseSchemaRegistry` and `DefaultDatabaseConnection` with unified `StratalDatabase` interface** — Consumers must update their type augmentations to use the new single interface with `schema`, `defaultConnection`, and `slicing` properties. - **`schema` moved from `DatabaseConnectionConfig` to `DatabaseModuleConfig`** — All connections now share a single schema; per-connection schema is no longer supported. - **Added `slicing` option to `DatabaseConnectionConfig`** — Connections can narrow available models via ZenStack slicing options. @@ -421,7 +434,6 @@ - **Removed `InferConnectionSchema` type** — Replaced by `InferDatabaseSchema` (shared) and `InferConnectionSlicing` (per-connection slicing). **`@stratal/testing`** - - **`TestingModule.getDb()` is now synchronous** — Returns `DatabaseService` directly instead of `Promise`. - **`TestingModule` creates a single request-scoped container at construction** — `container` property now returns the request-scoped container. The `runInRequestScope` pattern is removed. - **`TestingModule.close()` now disposes the request container** before shutting down the application. @@ -511,24 +523,20 @@ - Initial release of the Stratal framework — a modular Cloudflare Workers framework built on Hono and tsyringe. **Core Infrastructure** - - NestJS-style module system with `@Module()` decorator, dynamic modules (`forRoot`, `forRootAsync`), and lifecycle hooks (`OnInitialize`, `OnShutdown`) - Two-tier dependency injection container (global singletons + request-scoped) powered by tsyringe with conditional registration and service decoration - `StratalWorker` entry point extending Cloudflare's `WorkerEntrypoint` for HTTP fetch, queue batches, and scheduled cron triggers **Routing & API** - - Hono-based routing with `@Controller()` and `@Route()` decorators, automatic controller discovery, and route guards via `@UseGuards()` - OpenAPI schema generation with `@hono/zod-openapi` and Scalar API reference integration - NestJS-like middleware configuration with route-specific application and exclusion **Background Processing** - - Queue consumerfor Cloudflare Queues with `@Consumer()` and `@QueueJob()` decorators and batch processing - Cron job scheduling via `CronManager` integrated with Cloudflare's scheduled events **Services & Integrations** - - Email module with pluggable providers (Nodemailer, Resend) and queue-based sending - Storage module with AWS S3 / Cloudflare R2 support, multipart uploads, presigned URLs, and TUS resumable uploads - Internationalization (i18n) module with locale detection, message compilation, and request-scoped translations @@ -537,7 +545,6 @@ - Structured logging with JSON and pretty formatters **Developer Experience** - - Zod-powered request/response validation with type inference - Custom `ApplicationError` class with HTTP status mapping - ESM-only with full TypeScript decorator support (`emitDecoratorMetadata`) diff --git a/packages/testing/package.json b/packages/testing/package.json index 42430f0..3dba792 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, @@ -89,10 +89,10 @@ }, "peerDependencies": { "@better-auth/core": ">=1.6", - "@stratal/framework": ">=0.0.27", + "@stratal/framework": ">=0.1.0", "better-auth": ">=1.4", "pg": "^8.0.0", - "stratal": ">=0.0.27", + "stratal": ">=0.1.0", "vitest": "^4.1.0" }, "peerDependenciesMeta": { diff --git a/yarn.lock b/yarn.lock index a28f625..3d2bd5f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2668,11 +2668,11 @@ __metadata: peerDependencies: "@inertiajs/core": ">=3" "@inertiajs/react": ">=3" - "@stratal/inertia": ">=0.0.27" + "@stratal/inertia": ">=0.1.0" hono: ">=4" react: ">=19" react-dom: ">=19" - stratal: ">=0.0.27" + stratal: ">=0.1.0" peerDependenciesMeta: "@inertiajs/core": optional: true @@ -2721,7 +2721,7 @@ __metadata: "@zenstackhq/schema": ">=3.6" better-auth: ">=1.6" pg: ^8.0.0 - stratal: ">=0.0.27" + stratal: ">=0.1.0" languageName: unknown linkType: soft @@ -2746,11 +2746,11 @@ __metadata: peerDependencies: "@inertiajs/core": ">=3" "@inertiajs/react": ">=3" - "@stratal/inertia": ">=0.0.27" - "@stratal/testing": ">=0.0.27" + "@stratal/inertia": ">=0.1.0" + "@stratal/testing": ">=0.1.0" hono: ">=4" react: ">=19" - stratal: ">=0.0.27" + stratal: ">=0.1.0" vitest: ">=4" peerDependenciesMeta: "@inertiajs/core": @@ -2797,11 +2797,11 @@ __metadata: "@inertiajs/core": ">=3" "@inertiajs/react": ">=3" "@inertiajs/vite": ">=3" - "@stratal/testing": ">=0.0.27" + "@stratal/testing": ">=0.1.0" hono: ">=4" react: ">=19" react-dom: ">=19" - stratal: ">=0.0.27" + stratal: ">=0.1.0" vite: ">=8" vitest: ">=4" peerDependenciesMeta: @@ -2846,10 +2846,10 @@ __metadata: vitest: "npm:~4.1.11" peerDependencies: "@better-auth/core": ">=1.6" - "@stratal/framework": ">=0.0.27" + "@stratal/framework": ">=0.1.0" better-auth: ">=1.4" pg: ^8.0.0 - stratal: ">=0.0.27" + stratal: ">=0.1.0" vitest: ^4.1.0 peerDependenciesMeta: "@better-auth/core":