From 43652c14af9f457bb4034a5bbca910e9e6d4d6e5 Mon Sep 17 00:00:00 2001 From: Ajay Ghanwat Date: Wed, 19 Aug 2026 22:52:31 +0530 Subject: [PATCH] docs: specs for apis blocks, migration, and editor tooling Three specs completing the set, each depending on the one before it: - apis {}: one container, mode-less declarations, api.() callable anywhere with build-time dispatch, AsyncLocalStorage for server context, usage-driven emission, three render-binding forms. Replaces the ssr {} / client {} data blocks and the untypeable with($data) legacy body. - update: migrations to the new syntax. The legacy bare-body rewrite is deliberately manual -- which free identifiers are payload fields is not knowable from the source, so an automatic guess would compile and be wrong. - editor tooling: grammar, completions for api. and the api= attribute, diagnostics for removed constructs, and resolving by observation whether generated type errors surface inline. Co-Authored-By: Claude Opus 5 --- .../specs/2026-08-19-apis-block-design.md | 250 ++++++++++++++++++ .../specs/2026-08-19-editor-tooling-design.md | 131 +++++++++ .../2026-08-19-update-migration-design.md | 140 ++++++++++ 3 files changed, 521 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-19-apis-block-design.md create mode 100644 docs/superpowers/specs/2026-08-19-editor-tooling-design.md create mode 100644 docs/superpowers/specs/2026-08-19-update-migration-design.md diff --git a/docs/superpowers/specs/2026-08-19-apis-block-design.md b/docs/superpowers/specs/2026-08-19-apis-block-design.md new file mode 100644 index 00000000..4df08c88 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-apis-block-design.md @@ -0,0 +1,250 @@ +# The `apis { }` block and location-transparent dispatch — Design + +**Date:** 2026-08-19 +**Status:** Approved for implementation +**Scope:** One container block for API declarations, callable from anywhere as `api.(input)`, +dispatched in-process on the server and over `fetch` in the browser. + +**Depends on:** `2026-08-19-legacy-and-config-cleanup-design.md`. That spec removes the +compatibility surface and the `"legacy"` function runtime; this one removes the legacy `api` forms +by replacing them. + +## Goal + +Today a `.wrn` page has several unrelated ways to reach data: an `api` block inside `ssr {}`, an +`api` block inside `client {}`, a `server function` over RPC, a `load server` block, and hand-written +`fetch`. Each has different placement, different capabilities, and a different call shape. The +result is that "how do I fetch this?" has no single answer, and the answer that is right depends on +where the code happens to sit. + +This collapses the API half of that into one declaration and one call, and draws a line a developer +can hold in their head: + +- **`api.()`** — call an API route that exists as a real HTTP endpoint. +- **`server.()`** — call server-side logic that has no public surface. + +The question becomes "is there a route?", not "where am I running?". `server.()` is unchanged +by this spec. + +### Non-goals + +- Changing `server function`, actions, or `load` blocks. They keep working exactly as they do. +- External or third-party API targets. Still this app's `/api/*` routes only, preserving + `isSafeApiPath`. +- Any new configuration key. This spec adds none. +- Author-settable request headers, still excluded. + +## Decisions + +| Question | Decision | +| ---------------------- | ---------------------------------------------------------------------- | +| Container | Page-level `apis { }`, matching `functions { }` | +| Declaration | Mode-less — no `ssr` / `client` prefix | +| Call | `api.(input)` from any context | +| Dispatch | Chosen at build time: in-process on the server, `fetch` in the browser | +| Server request context | `AsyncLocalStorage` | +| Browser emission | Only blocks the client actually calls | +| Render binding | `api="name"`, `api="name()"`, `api="name({ … })"` | +| Old forms | Removed and replaced | + +## Syntax + +```wrn +apis { + searchUsers POST /api/users { + request { + body { + name?: string + age?: number + } + } + + response { return data.data.users } + error { return [] } + } + + listTeams GET /api/teams { + response { return data.data.teams } + } +} +``` + +`GET` and `HEAD` declare `parameters`, which become a query string; other methods declare `body`, +sent as JSON. The path stays a plain literal so `isSafeApiPath` is satisfied without relaxing it. + +Names are unique per page — `codegen.ts` already rejects duplicates, and that stays. + +### Why the container + +`functions { }` puts the modifier first inside a container named for the concept: +`functions { client function x() }`. Today's api form inverts that — `client { api x … }` — and is +the only construct in the language shaped that way, which is why APIs are hard to find in a page. +`apis` (plural) is the container; `api` remains the call namespace and the binding attribute. + +## Calling + +```wrn +functions { + client async function search(): Promise { + users = await api.searchUsers({ name: nameFilter }) + } +} + +load server directory { + return await api.searchUsers({ name: ctx.url.searchParams.get("name") ?? "" }) +} +``` + +The same call works in client functions, `load` blocks, actions, and server functions. Nothing at +the call site says where it runs. + +### Dispatch + +Dispatch is decided at build time, not by a runtime check. The compiler emits an `api` object into +each execution context, with the same member names and different transports behind them: + +- **Browser** — the existing `callApi` transport: query string or JSON body, `credentials: +"same-origin"`, `x-csrf-token` on non-GET, the established failure contract. +- **Server** — `callApiFromContext`, which dispatches to the route in-process. No network hop, no + serialisation round trip, and the caller's cookies, session, and locals are already forwarded. + +Two objects that never meet, so nothing ships to the browser that only the server uses, and the +runtime never branches on `typeof window` for something known at compile time. + +### `callApiFromContext` must learn to carry input + +It currently builds `new Request(apiUrl, { method, headers })` — no body, no query string. It has to +assemble the request the same way the browser transport does, from the same rules, or the two sides +will disagree about what an identical call sends. **The assembly rules must be shared, not +reimplemented**: a second copy will drift, and the drift will be silent because each side is tested +separately. + +### The request context + +A server-side call needs `ctx` to resolve the URL and forward cookies and session. `ctx` is not +uniformly available: `load` blocks have it, schema actions have it as a second parameter, plain +actions and server functions have neither. + +The server stores the request context in an `AsyncLocalStorage` at request entry, and the server +`api` object reads it. This is new machinery — the framework uses none today — and it must be +established in both the dev server and the production server, or a call that works in development +fails in production. + +When no context is present, the call throws with a message naming the block and explaining that an +API call needs a request context — never a silent `undefined`. + +## Emission + +A block's `response` and `error` bodies are page code. They ship to the browser **only when a +client-side call to that block exists**. The compiler already knows which `api.()` calls +appear in client functions. + +This keeps server-only transforms off the wire and the client bundle proportional to what it uses. +The consequence to know: adding the first client call to a block starts shipping that block's +bodies. Anything secret belongs in the endpoint, not in a `response` body. + +## Render binding + +A block can be bound into markup, which calls it during render and substitutes the result: + +```wrn +

loading…

+
    + {#each searchUsers as person}
  • {person.name}
  • {/each} +
+``` + +Three accepted forms: `api="name"`, `api="name()"` — equivalent — and `api="name({ … })"`, which +passes arguments. The argument expression is evaluated in the same scope as other view expressions +at render time. This mirrors `@click="search()"`, so it introduces no new escaping or naming rules. + +### The edge this creates, stated plainly + +A block that is both render-bound and called from code **runs twice** — once for the binding, once +for the call. They are two different lifecycles wearing one name, and no deduplication is attempted: +a render-time fetch and a user-triggered fetch are usually meant to be different requests, and +silently collapsing them would be worse than the duplication. Authors binding a block _and_ calling +it should expect two requests. + +## Replacing the old forms + +`ssr { … }` and `client { … }` data blocks are removed. Each could contain only two things, and both +have a home: + +| Old | New | +| ------------------------------------------------ | ------------------------------------------------------ | +| `ssr { api x … }` / `client { api x … }` | `apis { x … }` | +| `ssr { functions { function helper() } }` | `functions { shared function helper() }` | +| legacy bare-body `api x GET /p { return users }` | `apis { x GET /p { response { return data.users } } }` | + +The legacy bare body injected the payload with `with ($data ?? {})`, which is **untypeable** — +TypeScript cannot see through `with`, and that is the entire reason sectioned blocks bind a named +`data`. Removing the legacy form removes that fork: one payload binding, typed. + +`client state { }` is a different construct that shares the keyword and is **not** affected. + +`examples/basic-app/app/pages/hello.wrn` uses both old forms and is the migration's worked example. + +## Type safety + +Assertions are generated into `app/types/wrnexus.generated.api-checks.ts` and checked by the +project's own `tsc`, as established. Two changes follow from mode-less declarations: + +- The current generator skips blocks whose `mode !== "client"`. That skip exists because an `ssr` + block could never declare a `request`. Mode-less blocks invalidate the reasoning, so **every block + with declared fields gets an assertion**. +- The zero-field skip stays: a block with no declared fields has nothing to check, and asserting + `Record` against a contract fails spuriously. + +## Known edges + +- **`state api` stops working.** The name is currently excluded from destructuring only when a page + has client api blocks, so pages without them keep using it. Once `api` is universal that + protection goes, and a page with `state api` breaks. It is a build-time failure, not silent. +- **Every block is browser-reachable in principle.** The routes were already publicly reachable by + `fetch`, so this exposes no new surface — but a block is no longer implicitly server-only by + virtue of its placement. + +## Testing + +**Parser** + +- `apis { }` parses multiple mode-less entries; duplicate names are rejected. +- `ssr { api … }` and `client { api … }` are rejected with a message naming the replacement. +- All three binding forms parse, including an argument expression containing a nested object. + +**Dispatch** + +- A client-side call issues one `fetch` with the expected URL, method, body, and CSRF header. +- A server-side call dispatches in-process and issues **no** network request — asserted by observing + that no fetch occurs, not merely that the result is right. +- A server-side call with no request context throws a message naming the block. +- The same declared input produces the same request on both sides — the shared-assembly guard. + +**Emission** + +- A block called only from server code does not appear in the browser module. +- A block called from a client function does. + +**Render binding** + +- Each of the three forms renders the resolved value. +- A bound block that is also called issues two requests, pinning the documented edge. + +**Type safety** + +- A block declaring a field its endpoint rejects fails `bun run typecheck`, asserted by running + `tsc` and reading its diagnostics — not by matching generated text. + +**End to end** + +- `examples/basic-app` migrated to `apis { }`, driven in a browser: a client call updates state, a + render-bound block appears in the served HTML, and a deliberately failing call takes the `error` + path. + +## Deferred + +- External API targets, and the allowlist and credential handling they need. +- Author-settable headers. +- Deduplicating a render-bound block against a code call. +- Response caching and request de-duplication. diff --git a/docs/superpowers/specs/2026-08-19-editor-tooling-design.md b/docs/superpowers/specs/2026-08-19-editor-tooling-design.md new file mode 100644 index 00000000..52e7f3a4 --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-editor-tooling-design.md @@ -0,0 +1,131 @@ +# Editor support for the new syntax — Design + +**Date:** 2026-08-19 +**Status:** Approved for implementation +**Scope:** Language server and VS Code extension updated for `apis { }`, the `api.()` call, and +the removal of the mode data blocks. + +**Depends on:** `2026-08-19-apis-block-design.md`. The syntax must exist before the editor can +describe it. + +## Goal + +A syntax change that the editor does not know about is worse than no change: valid code is +red-underlined, removed constructs still autocomplete, and the new block gets no highlighting. This +spec keeps the tooling level with the language. + +It also settles a question left open by earlier work: whether type errors from the generated +assertions actually appear inside the `.wrn` file, or only in the generated file. That was never +verified — it was inferred from reading source — and inference is not good enough for the thing +developers rely on to tell them their code is wrong. + +### Non-goals + +- New editor features unrelated to this syntax change. +- Editors other than VS Code beyond what standard LSP provides. +- HTML formatting — `formatWrn` still owns markup. + +## What exists + +- **Grammar:** `editors/vscode/syntaxes/wrn.tmLanguage.json` names block keywords directly — + `api` appears 4 times, `client` 6, `server` 5, `ssr` once. +- **Keyword list:** `WRN_KEYWORDS` in `packages/language-server/src/index.ts` drives completion and + includes `api` but not `apis`. +- **Extension completions:** `editors/vscode/src/completion.js` carries block snippets. +- **Server features:** completion, hover, folding, linked editing, tag completion, and TypeScript + diagnostics over a virtual document. + +## Changes by surface + +### Grammar + +Add `apis` as a block keyword. Remove the `ssr` / `client` **data block** patterns, keeping `client` +and `server` where they mean other things — `client state { }`, the `runtime` values, and the +function modifiers in `functions { }`. This is the change most likely to over-reach: `client` is one +word with several jobs in this language, and blanket removal would un-highlight constructs that +still exist. + +Highlight an `apis` entry's shape — name, method, path — so a declaration reads as a declaration +rather than as loose identifiers. + +### Keyword and block completion + +- `apis` joins `WRN_KEYWORDS`. +- A snippet for the container and a snippet for an entry, including the `request` / `response` / + `error` sections, so the shape is discoverable without the docs. +- `ssr` and `client` data-block snippets are removed from `completion.js`. Offering a construct the + compiler rejects is worse than offering nothing. + +### Call completion — the feature worth building + +Inside a function body, `api.` should complete to the names declared in that page's `apis { }` +block, with the method and path as detail. The server already indexes the document to build +completions, and the block names are in the AST. + +This is where the syntax pays off in the editor: the set of legal calls is knowable, so the editor +should know it. Without it, `api.` is an empty namespace and every call is typed from memory. + +Hovering a name inside `api.()` shows its method, path, and declared request fields. + +### The `api=` attribute in markup + +`api="searchUsers({ name: nameFilter })"` is an attribute whose value is a call expression. The HTML +service must not flag it as an unknown attribute, and the expression must not be treated as plain +text. Completion inside the quotes offers the page's block names, matching the `api.` behaviour. + +### Diagnostics for removed constructs + +An `ssr { api … }` or `client { api … }` block gets a diagnostic naming `apis { }` as the +replacement, positioned on the block keyword. The compiler already rejects these; the editor should +say so while typing rather than at build time, and it should say what to do instead. + +## Inline type errors — verifying, not assuming + +The `apis` spec generates assertions into `app/types/wrnexus.generated.api-checks.ts`, and `tsc` +fails when a block declares a field its endpoint rejects. Whether that failure surfaces **inside the +`.wrn` file** has never been confirmed. + +This spec resolves it in two steps, in order: + +1. **Observe the current behaviour.** With a deliberately wrong field in place, open the page in VS + Code and record where the error appears: on the block, only in the generated file, or nowhere. +2. **Act on what is observed.** If the error already surfaces usefully, document it and stop. If it + appears only in the generated file, map the diagnostic back to the block that produced it — the + generator knows which page and block each assertion came from, so the mapping is available if it + is recorded rather than discarded. + +If step 2 proves larger than this spec can hold, it becomes its own work, and the spec says so +plainly rather than leaving an unfinished feature implied. **Nothing here should claim inline +diagnostics work until someone has seen them work.** + +## Testing + +**Grammar** — a fixture page using `apis { }`, `client state { }`, `functions { shared function }`, +and `runtime = "client"` tokenizes correctly; `client` keeps its highlighting everywhere it is still +valid. This is the guard against over-reaching removal. + +**Completion** + +- `apis` is offered at page level; `ssr` / `client` data blocks are not. +- `api.` inside a function body offers the page's declared names with method and path. +- Inside `api="…"` in markup, the same names are offered. +- Outside those contexts, completion is unchanged — the guard that non-API editing is undisturbed. + +**Hover** — a name inside `api.()` reports its method, path, and request fields. + +**Diagnostics** — an `ssr { api … }` block produces a diagnostic naming `apis { }`, positioned on +the block keyword. + +**Bundles** — `check:editor-compiler`, `check:editor-language-server`, and +`check:editor-extension` pass. These embed the compiler and language server, so they must be rebuilt +after the syntax change; a stale bundle fails the gate. + +**Manual, and recorded in the implementation notes** — open the migrated `examples/basic-app` in VS +Code: `apis { }` highlights, `api.` completes, a removed construct is flagged, and the inline +type-error question above is answered by observation. + +## Deferred + +- Mapping generated assertion diagnostics back into `.wrn`, if step 2 above proves too large. +- Moving the remaining component intelligence out of `completion.js` and into the server. +- Editors other than VS Code. diff --git a/docs/superpowers/specs/2026-08-19-update-migration-design.md b/docs/superpowers/specs/2026-08-19-update-migration-design.md new file mode 100644 index 00000000..3b8f0eff --- /dev/null +++ b/docs/superpowers/specs/2026-08-19-update-migration-design.md @@ -0,0 +1,140 @@ +# Carrying projects to the new syntax with `wrnexus update` — Design + +**Date:** 2026-08-19 +**Status:** Approved for implementation +**Scope:** Migrations that take an existing project from today's syntax to the syntax left by the +cleanup and `apis { }` specs. + +**Depends on:** `2026-08-19-legacy-and-config-cleanup-design.md` and +`2026-08-19-apis-block-design.md`. Both define the target this migrates to, so both must land first. + +## Goal + +After the two preceding specs, every existing project is written in a syntax the framework no longer +accepts. One `wrnexus update` should carry a project across — config keys removed, `api` blocks +moved into `apis { }`, mode-scoped helpers relocated — and, where it cannot do that safely, say so +precisely instead of guessing. + +## What already exists + +This is an extension of working machinery, not a new subsystem: + +- `Migration { version, id, description, apply(ctx) }`, run when `from < version <= to`. +- `MigrationCtx` carries `appRoot`, `from`, `to`, **`dryRun`**, a report, and a logger. +- `MigrationReport` already separates `changedAutomatically`, `needsReview`, `parseFailures`, + `ambiguousFunctions`, and `unresolvedImports`. +- `update.ts` already imports `parse` and `formatWrn` from `@wrnexus/syntax`, so parsing a `.wrn` + file, transforming it, and re-emitting formatted source is an established pattern here. + +The report's shape matters: it was built around the idea that some changes are safe to make and +others must be handed back to a human. That distinction is the backbone of this spec. + +### Non-goals + +- Migrating projects below `0.8.0`. The cleanup spec removes those migrations; no such project + exists. +- Rewriting application logic. Only the constructs these specs changed. + +## The safety contract + +**A file is transformed correctly, or it is left untouched and reported.** There is no third +outcome. Concretely: + +1. Parse the file. A parse failure records the path in `parseFailures` and moves on — the file is + never partially rewritten. +2. Transform, then re-emit through `formatWrn`. +3. If any part of a file's transform cannot be completed, **the whole file is skipped** and recorded + in `needsReview` with the reason and the construct involved. + +Running the migration twice must be a no-op: every transform detects already-migrated input and +does nothing. Dry-run must report exactly what a real run would change. + +## The migrations + +### 1. Remove the dead config keys + +Delete `compatibility`, `functions`, `compatibilityDate`, and `frameworkBehaviour` from +`wrnexus.config.ts`. Mechanical, fully automatic, and safe because none of them was ever read. + +### 2. `ssr { api … }` / `client { api … }` → `apis { … }` + +Move each `api` entry into a page-level `apis { }` block, dropping the mode. Sectioned bodies — +those already using `request` / `response` / `error` — carry across unchanged, because the payload +is already bound to `data`. + +Fully automatic. If a page has entries in both an `ssr` and a `client` block sharing a name, that is +a duplicate under the new rules and the **file is skipped and reported**, since choosing which one +survives is a decision about intent. + +### 3. `ssr { functions { … } }` → `functions { shared function … }` + +Relocate mode-scoped helpers to the page-level `functions { }` block with the `shared` modifier. +Automatic. If the page already has a function of the same name, the file is skipped and reported. + +### 4. Legacy bare-body `api` blocks — **needs review, not automatic** + +This is the one transform that cannot be done safely, and the spec is explicit about it rather than +attempting a best effort. + +A legacy bare body is evaluated inside `with ($data ?? {})`, so it references payload fields as bare +identifiers: + +```wrn +api ssrUsers GET /api/users/ssr { + return userNames(users) +} +``` + +The sectioned form binds the payload to `data`, so this must become `data.users`. But **which free +identifiers are payload fields is not knowable from the source.** In the example, `users` comes from +the response and `userNames` is a page helper — and nothing in the file distinguishes them. The +response shape belongs to the route, and the route may not even be typed. + +A migration that guessed would produce code that compiles and is wrong: `data.userNames(...)` or an +untouched `users` that silently resolves to `undefined`. That is precisely the silent-wrong-answer +failure this project keeps paying for. + +So: legacy bare-body blocks are **detected, reported in `needsReview` with the file, the block name, +and the free identifiers found**, and left untouched. The report tells the author exactly what to +decide. `wrnexus update` prints a short explanation of why this one is manual. + +### 5. Deprecated `@wrnexus/auth` options + +Only if the cleanup spec's optional auth section is included. Rename call sites of the superseded +options. Automatic where the rename is unambiguous; reported otherwise. + +## Version + +All of these attach to the release that ships the breaking change. After the cleanup spec the +migration floor is `0.8.0`, so the list is short and every entry is reachable. + +## Output + +At the end of a run the command prints, in this order: what it changed, what needs review and why, +and what failed to parse. A run with anything in `needsReview` or `parseFailures` exits non-zero, so +a scripted upgrade cannot appear to succeed while leaving a project half-migrated. + +## Testing + +Each migration gets a fixture project and three assertions: the transform produces the expected +source, running it a second time changes nothing, and a dry run reports the same set without writing. + +- **Config removal** — keys gone, rest of the config untouched. +- **`api` relocation** — a page with both `ssr` and `client` api blocks lands in one `apis { }`; + entries keep their names, methods, paths, and sections. +- **Name collision across modes** — the file is skipped and reported, not silently merged. +- **Mode functions** — relocated with the `shared` modifier; a name collision skips and reports. +- **Legacy bare body** — reported in `needsReview` with the block name and free identifiers, and the + file is byte-identical afterwards. This is the most important test in the spec: it pins that the + migration does _not_ attempt the rewrite. +- **Parse failure** — a malformed `.wrn` is recorded in `parseFailures` and left untouched. +- **Exit code** — non-zero when anything needs review. +- **End to end** — `examples/basic-app` migrated by the command alone, then built and tested. If the + framework's own example cannot be migrated by the tool, the tool is not finished. + +## What this does not promise + +Automated source rewriting cannot be promised as "perfect". What is promised is bounded: every file +is either correctly transformed or untouched and named in the report, with the reason. Nothing is +half-rewritten, and nothing is guessed. The legacy bare-body case is deliberately manual because a +correct automatic answer does not exist.