From c4ea8d1a89b50bd9c75992b9c51f822af5a06038 Mon Sep 17 00:00:00 2001 From: Rickylabs Date: Sun, 12 Jul 2026 00:03:49 +0200 Subject: [PATCH 1/4] docs(web-layer): type the query.md fetch examples + coverage matrix Replace all three raw-fetch queryFn/mutationFn examples in web-layer/query.md with the typed contract chain (createServiceClient -> createServiceQueryUtils -> queryOptions()/mutationOptions() spread into useIslandQuery/useIslandMutation), add a single lib/api-clients.ts module, and keep one raw fetch under an explicit 'endpoints without a NetScript contract' heading. Cross-link sdk.md <-> query.md (the 'hand-rolled fetch wrapper tax'). Add the unpublished web-layer coverage matrix under _plan/. Proposals #1, #16. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VifzAytcxBk5odEMaCXihM --- docs/site/_plan/web-layer-coverage-matrix.md | 92 ++++++++++ docs/site/services-sdk/sdk.md | 6 +- docs/site/web-layer/query.md | 167 ++++++++++++++----- 3 files changed, 224 insertions(+), 41 deletions(-) create mode 100644 docs/site/_plan/web-layer-coverage-matrix.md diff --git a/docs/site/_plan/web-layer-coverage-matrix.md b/docs/site/_plan/web-layer-coverage-matrix.md new file mode 100644 index 000000000..f63613af1 --- /dev/null +++ b/docs/site/_plan/web-layer-coverage-matrix.md @@ -0,0 +1,92 @@ +# Web-layer pillar coverage matrix (unpublished plan artifact) + +Scope: every shipped `@netscript/fresh` and `@netscript/fresh-ui` **frontend** capability that a +docs reader would build against, scored against its current docs treatment. Grounded in: + +- `deno doc` over `packages/fresh/src/application/route/mod.ts`, + `packages/fresh/src/application/query/mod.ts`, `packages/sdk/src/query/mod.ts`, and + `packages/sdk/src/query-client/mod.ts` (shipped surface, not memory); +- the two reference apps' real call-sites — a centralized single-file typed-client module + (`createServiceClient` per service, contracts from a shared alias, Aspire discovery) and a + generated `routes` registry built with `createRouteReference(...$route, { id, kind })` consumed + via `.withRoute(...)` in every page; +- the scaffold's own `routes` registry seed (`createRouteReference(routePatterns.X.$route, ...)`). + +Treatment legend: **good** = shipped surface shown end-to-end with a runnable, typed example; +**thin** = mentioned or partially shown but not connected to the typed chain; **missing** = shipped +capability with no worked treatment on the page a reader would land on. + +## `@netscript/fresh/route` — route contracts + +| Capability | Shipped symbol(s) | Docs page | Treatment | Notes | +| --- | --- | --- | --- | --- | +| Route reference from a pattern | `createRouteReference` | `web-layer/route.md` | good | Full walkthrough; `.href()` shown. | +| Typed route contract | `defineRouteContract`, `bindRoutePattern` | `web-layer/route.md` | good | `bind()` + parse helpers shown. | +| Enum path params | `enumPathParamSchema`, `defineEnumPathParam` | `web-layer/route.md` | good | Both forms documented. | +| Pagination search schema | `paginationSearchSchema`, `fallback`, `.extend` | `web-layer/route.md` | good | `offset` derivation shown. | +| Path/search parsing | `parsePath`/`parseSearch`, `safeParse*` | `web-layer/route.md` | good | Contract + reference both expose. | +| Testing bound-contract parsing | `parsePath`/`parseSearch` under test | `web-layer/testing.md` | thin → **good (this run)** | Added a `Deno.test` exercising a bound contract's `safeParsePath`/`parseSearch`. | +| Route registry consumed by pages | `createRouteReference(...$route)` + `.withRoute` | `web-layer/interactive.md`, `web-layer/examples.md` | missing → **good (this run)** | Interactive/examples now show a typed route reference feeding a `QueryIsland`. | +| Paired page + partial | `withPartial`, `PairedRouteTarget` | `web-layer/route.md` | good | Documented; optimistic-partial combo remains a future cross-link. | + +## `@netscript/fresh/query` — island data layer + +| Capability | Shipped symbol(s) | Docs page | Treatment | Notes | +| --- | --- | --- | --- | --- | +| Island query hook | `useIslandQuery`, `QueryIsland` | `web-layer/query.md` | anti-pattern → **good (this run)** | queryFn was raw `fetch`; now spreads typed `queryOptions()`. | +| Polling query | `refetchInterval` | `web-layer/query.md` | anti-pattern → **good (this run)** | Same conversion for the poll example. | +| Island mutation | `useIslandMutation` | `web-layer/query.md` | anti-pattern → **good (this run)** | mutationFn was raw `fetch`; now spreads typed `mutationOptions()`, optimistic rollback preserved. | +| Infinite query | `useIslandInfiniteQuery` | `web-layer/query.md` | good | Options/result documented. | +| Live queries | `useLiveQuery`, `useLiveSuspenseQuery` | `web-layer/query.md` | good | Documented in the hooks section. | +| SSR prefetch/hydration | `dehydrateQueryClient`, `HydrationBoundary`, `QueryHydrationScript` | `web-layer/query.md` | good | Server→island handoff shown. | +| Cache-entry helpers | `hasAllCacheEntries`, `minCachedAt`, `projectCachedItemFromList` | `web-layer/query.md`, `web-layer/examples.md` | good | Loader cache-first pattern shown. | + +## `@netscript/sdk` bridge — contract → client → island + +| Capability | Shipped symbol(s) | Docs page | Treatment | Notes | +| --- | --- | --- | --- | --- | +| Typed service client | `createServiceClient` | `services-sdk/sdk.md`, `web-layer/fresh-ui.md` | good | Single-clients-file spine documented. | +| Frontend query utils bridge | `createServiceQueryUtils` | `web-layer/query.md` | missing → **good (this run)** | The literal bridge from a client to `useIslandQuery`/`useIslandMutation`; was absent from the query page. | +| Cache-first server factory | `createQueryFactories`, `defineServices` | `services-sdk/sdk.md` | good | KV-backed SWR variant; cross-linked from query.md. | +| Client-side invalidation | `bridgeInvalidation`, `toClientKeyPrefix` | `services-sdk/sdk.md` | good | `clientKey()` vs `key()` split explained. | +| sdk ↔ query cross-link | — | `services-sdk/sdk.md` ↔ `web-layer/query.md` | missing → **good (this run)** | Bidirectional link now closes the "hand-rolled fetch wrapper tax" loop (proposal #16). | + +## `@netscript/fresh/form` — server-validated forms + +| Capability | Shipped symbol(s) | Docs page | Treatment | Notes | +| --- | --- | --- | --- | --- | +| Managed form + state | `Form`, `resolveFormState`, `FormState` | `web-layer/form.md` | good | GET/failed-POST round-trip shown. | +| Schema validation | `createStandardSchemaAdapter`, `toFormErrors` | `web-layer/form.md` | good | Standard Schema v1 adapter shown. | +| CSRF + idempotency | `generateCsrfToken`, `getSubmissionHiddenInputProps` | `web-layer/form.md` | good | Both covered. | +| Form → typed service mutation (write path) | `Form` + `createServiceQueryUtils().mutationOptions()` | `web-layer/form.md` | thin → **good (this run)** | Validated payload now posts through a typed mutation, closing the read/write asymmetry (proposal #14). | + +## `@netscript/fresh/interactive`, `/testing`, examples + +| Capability | Shipped symbol(s) | Docs page | Treatment | Notes | +| --- | --- | --- | --- | --- | +| Suspense promise read | `usePromise`, `resolvedPromise` | `web-layer/interactive.md` | good | Documented. | +| Typed query island (islands primer) | `QueryIsland` + typed `queryOptions()` | `web-layer/interactive.md` | thin → **good (this run)** | Added a typed query-island example so the primer shows what makes NetScript islands distinct. | +| Runnable examples index | root cache helpers | `web-layer/examples.md` | thin → **good (this run)** | Added a typed route + `QueryIsland` runnable block. | +| Mock route context | `createMockRouteContext` | `web-layer/testing.md` | good | Loader/layout tests shown. | +| Defer policy fixtures | `createMockDeferPolicy` | `web-layer/testing.md` | good | Profiles documented. | + +## Not in scope for this slice (owned elsewhere / already good) + +- `web-layer/fresh-ui.md`, `web-layer/server.md`, `web-layer/builders.md`, `web-layer/route.md`, + `web-layer/defer-streaming-ui.md`, `web-layer/error.md`, `web-layer/vite.md` — scored **good** on + the typed-surface axis; no raw-`fetch`/manual-parse anti-pattern. +- Tutorial-series showcase gaps (live-dashboard pagination, workspace route-authz, storefront + frontend chapter, chat stream-proxy callout) — audit proposals #2–#6, owned by other #660 slices. + +## Highlights + +- **Single highest-impact fix landed:** `web-layer/query.md` no longer teaches raw `fetch` in + `queryFn`/`mutationFn`. All three examples now flow contract → `createServiceClient` → + `createServiceQueryUtils` → `queryOptions()`/`mutationOptions()` → `useIslandQuery`/ + `useIslandMutation`, matching the pattern already shown one page over in `web-layer/fresh-ui.md`. +- **One honest escape hatch retained:** a single raw-`fetch` example survives under an explicit + "endpoints without a NetScript contract" heading, so the page still shows the untyped fallback + without presenting it as the default. +- **Read/write symmetry closed:** `web-layer/form.md` now shows the validated write path through a + typed service mutation, and `web-layer/interactive.md`/`examples.md`/`testing.md` each connect to + the typed route/query surface that distinguishes NetScript islands from vanilla Fresh islands. diff --git a/docs/site/services-sdk/sdk.md b/docs/site/services-sdk/sdk.md index e02a57058..780ccf1db 100644 --- a/docs/site/services-sdk/sdk.md +++ b/docs/site/services-sdk/sdk.md @@ -47,9 +47,9 @@ hydrate that same query key on the client. ## Declare once, derived everywhere -The classic data-layer tax is the hand-rolled fetch wrapper: a `lib/api.ts` full of `fetch()` -calls whose request and response types are re-typed by hand from whatever the server currently -returns. Every server change costs a second edit in the wrapper, a third in the query keys, and +The classic data-layer tax is the [hand-rolled fetch wrapper](/web-layer/query/): a `lib/api.ts` +full of `fetch()` calls whose request and response types are re-typed by hand from whatever the +server currently returns. Every server change costs a second edit in the wrapper, a third in the query keys, and whichever one is forgotten becomes a runtime bug. For an AI agent working the codebase the tax is worse — each of those copies is a separate turn, and a stale one is a wrong answer the agent cannot see until runtime. diff --git a/docs/site/web-layer/query.md b/docs/site/web-layer/query.md index 0bd6f859e..dfbbf3436 100644 --- a/docs/site/web-layer/query.md +++ b/docs/site/web-layer/query.md @@ -123,18 +123,61 @@ through the shared client; its options come from `IslandQueryOptions`: The hook returns an `IslandQueryResult` with `data`, `error`, `status`, `isLoading`, `isSuccess`, `isError`, and a `refetch()` method. +### From contract to island: the typed query chain + +`queryFn` can be any function that returns data — but on a NetScript service it +should almost never be a hand-written `fetch`. A raw `fetch("/api/widgets")` +re-types the response by hand, hardcodes the path, and drifts silently the moment +the service changes. The [`@netscript/sdk`](/services-sdk/sdk/) bridge removes +that gap: derive the `queryFn` (and its `queryKey`) straight from the same oRPC +contract the service implements, so the island cannot drift from the API. + +The chain has three links, and it lives in **one module** per app — the single +source of typed clients every loader and island imports: + +```ts +// apps/dashboard/lib/api-clients.ts +import { createServiceClient } from "@netscript/sdk/client"; +import { createServiceQueryUtils } from "@netscript/sdk/query-client"; +import { docsContract, todosContract, widgetsContract } from "@contracts"; + +// 1. A typed client per service. `serviceName` resolves a URL via Aspire +// discovery; the contract drives every method signature. +export const widgetsClient = createServiceClient({ + contract: widgetsContract, + serviceName: "widgets", +}); +export const docsClient = createServiceClient({ + contract: docsContract, + serviceName: "docs", +}); +export const todosClient = createServiceClient({ + contract: todosContract, + serviceName: "todos", +}); + +// 2. Frontend query utils per client. Each exposes `.queryOptions()`, +// `.mutationOptions()`, `.infiniteOptions()`, and `.key()` for every +// procedure on the contract — all inferred, no manual annotations. +export const widgets = createServiceQueryUtils(widgetsClient, { path: ["widgets"] }); +export const docs = createServiceQueryUtils(docsClient, { path: ["docs"] }); +export const todos = createServiceQueryUtils(todosClient, { path: ["todos"] }); +``` + +Islands then import from that module and spread the generated options into the +island hooks. `queryOptions({ input })` supplies both the `queryKey` and a +`queryFn` bound to the contract, so the hook call only adds island concerns like +`staleTime`: + ```tsx +// apps/dashboard/islands/WidgetIsland.tsx import { QueryIsland, useIslandQuery } from "@netscript/fresh/query"; - -interface Widget { - id: string; - name: string; -} +import { widgets } from "../lib/api-clients.ts"; function WidgetView() { - const query = useIslandQuery({ - queryKey: ["widgets"], - queryFn: () => fetch("/api/widgets").then((res) => res.json()), + // queryKey + queryFn come from the contract; the input is typed, too. + const query = useIslandQuery({ + ...widgets.list.queryOptions({ input: {} }), staleTime: 30_000, }); @@ -157,6 +200,14 @@ export default function WidgetIsland() { } ``` +`widgets.list.queryOptions(...)` returns a typed `{ queryKey, queryFn }`; the +`widget` element is inferred from the contract's output, so a renamed field is a +compile error, not a runtime surprise. The `@netscript/sdk/query-client` bridge +(`createServiceQueryUtils`) is the pure client-to-island path; when you also want +KV-backed server SWR and prefetch, `createQueryFactories` from the SDK adds a +cache-first layer over the same contract — see the +[Typed SDK & client](/services-sdk/sdk/) pillar for that variant. + `initialData` is the bridge between a server loader and the island: pass the loader's cached payload as `initialData` so the island renders with data immediately and only refetches once `staleTime` elapses. @@ -171,17 +222,13 @@ continue while the tab is backgrounded (the default stops polling on blur). ```tsx import { useIslandQuery } from "@netscript/fresh/query"; - -interface Doc { - id: string; - status: "pending" | "embedding" | "ready"; -} +import { docs } from "../lib/api-clients.ts"; function DocStatus({ id }: { id: string }) { - const query = useIslandQuery({ - queryKey: ["doc", id], - queryFn: () => fetch(`/api/docs/${id}`).then((res) => res.json()), - // Poll every 2s until ready, then stop by returning false. + const query = useIslandQuery({ + // Contract-derived queryKey + queryFn; `id` is the typed procedure input. + ...docs.getById.queryOptions({ input: { id } }), + // Poll every 2s; flip to false from state once the status reaches "ready". refetchInterval: 2_000, refetchIntervalInBackground: true, }); @@ -227,11 +274,13 @@ the cached list and returns it as context, `onError` restores that snapshot, and `onSettled` re-syncs with the server: ```tsx +// apps/dashboard/islands/TodoIsland.tsx import { QueryIsland, useIslandMutation, useQueryClient, } from "@netscript/fresh/query"; +import { todos } from "../lib/api-clients.ts"; interface Todo { id: string; @@ -241,40 +290,42 @@ interface Todo { function TodoToggle({ todo }: { todo: Todo }) { const queryClient = useQueryClient(); + const listKey = todos.list.queryKey({ input: {} }); - const toggle = useIslandMutation({ - // 1. The round-trip: PATCH the change and return the server's copy. - mutationFn: (done) => - fetch(`/api/todos/${todo.id}`, { - method: "PATCH", - body: JSON.stringify({ done }), - }).then((res) => res.json() as Promise), + const toggle = useIslandMutation({ + // 1. The round-trip: mutationOptions() supplies the contract-bound + // mutationFn and mutationKey; `mutate` takes the typed procedure input. + ...todos.update.mutationOptions(), // 2. Optimistic update: snapshot prior state, write the expected next state. - onMutate: async (done) => { - await queryClient.cancelQueries({ queryKey: ["todos"] }); - const previous = queryClient.getQueryData(["todos"]); + onMutate: async (variables: { id: string; done: boolean }) => { + await queryClient.cancelQueries({ queryKey: listKey }); + const previous = queryClient.getQueryData(listKey); queryClient.setQueryData( - ["todos"], - (list) => (list ?? []).map((item) => item.id === todo.id ? { ...item, done } : item), + listKey, + (list) => + (list ?? []).map((item) => + item.id === variables.id ? { ...item, done: variables.done } : item + ), ); return { previous }; }, // 3. Roll back to the snapshot when the server rejects the change. - onError: (_error, _done, context) => { - if (context?.previous) queryClient.setQueryData(["todos"], context.previous); + onError: (_error, _variables, context) => { + const snapshot = context as { previous?: Todo[] } | undefined; + if (snapshot?.previous) queryClient.setQueryData(listKey, snapshot.previous); }, // 4. Re-sync with the server once the mutation settles, either way. - onSettled: () => queryClient.invalidateQueries({ queryKey: ["todos"] }), + onSettled: () => queryClient.invalidateQueries({ queryKey: listKey }), }); return ( @@ -290,11 +341,50 @@ export default function TodoIsland({ todo }: { todo: Todo }) { } ``` -The four generic parameters mirror -`IslandMutationOptions`: here `TData` is the -server's `Todo`, `TVariables` is the `boolean` passed to `mutate`, and `TContext` is -the snapshot threaded from `onMutate` into `onError` and `onSettled`. Call `mutate` -for fire-and-forget UI updates, or `mutateAsync` when you need to await the result. +Spreading `todos.update.mutationOptions()` supplies the contract-bound +`mutationFn` and `mutationKey`; the optimistic seam — `onMutate`, `onError`, +`onSettled` — is added on the island hook, exactly where the query cache lives. +`mutate` takes the typed procedure input (`{ id, done }` here), and the +`todos.list.queryKey({ input })` helper builds the same key the list query uses, +so the snapshot, rollback, and invalidation all target the right cache entry. +Call `mutate` for fire-and-forget UI updates, or `mutateAsync` when you need to +await the result. + +### Endpoints without a NetScript contract + +Sometimes an island calls something that has no oRPC contract — a third-party +REST API, a legacy endpoint, or a webhook receiver. That is the one case where a +hand-written `queryFn` is correct: there is no contract to derive from, so you own +the request and its type. Keep it explicit and isolated so it does not read as the +default pattern: + +```tsx +import { useIslandQuery } from "@netscript/fresh/query"; + +interface Rate { + base: string; + value: number; +} + +// No NetScript contract exists for this external endpoint, so the fetch and its +// response type are written by hand — the deliberate exception, not the norm. +function ExchangeRate() { + const query = useIslandQuery({ + queryKey: ["exchange-rate", "USD"], + queryFn: () => + fetch("https://api.example.com/rates/USD").then( + (res) => res.json() as Promise, + ), + staleTime: 60_000, + }); + + return {query.data ? `1 ${query.data.base} = ${query.data.value}` : "…"}; +} +``` + +For any endpoint that *does* have a NetScript contract, reach for the typed chain +above instead — the raw `fetch` here exists only because there is nothing typed to +bind to. ## Server prefetch and hydration @@ -362,6 +452,7 @@ Query subpath (`@netscript/fresh/query`): {{ comp.cardsGrid({ columns: 3, cards: [ { title: "The Fresh page model", body: "How server pages compose and load.", href: "/web-layer/server/" }, { title: "Routing and route contracts", body: "Route definitions and loaders.", href: "/web-layer/route/" }, + { title: "Typed SDK & client", body: "The contract → client → query-utils bridge queryFn is built on.", href: "/services-sdk/sdk/" }, { title: "Interactive islands", body: "Where query hooks run on the client.", href: "/web-layer/interactive/" }, { title: "Deferred and streaming UI", body: "Stream data into hydrating islands.", href: "/web-layer/defer-streaming-ui/" }, { title: "Server-validated forms", body: "Pair mutations with form handling.", href: "/web-layer/form/" }, From 9aec7862b6fd60e84aed5e7946d47720faf8c105 Mon Sep 17 00:00:00 2001 From: Rickylabs Date: Sun, 12 Jul 2026 00:04:00 +0200 Subject: [PATCH 2/4] docs(web-layer): connect form.md to a typed service mutation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a 'Posting the validated payload through a typed mutation' section: the server handler writes through the typed SDK client, and an enhanced island drives the same contract write with useIslandMutation + mutationOptions() — closing the read/write asymmetry with query.md. Proposal #14. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VifzAytcxBk5odEMaCXihM --- docs/site/web-layer/form.md | 53 +++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/docs/site/web-layer/form.md b/docs/site/web-layer/form.md index 0334ed1ed..523fed433 100644 --- a/docs/site/web-layer/form.md +++ b/docs/site/web-layer/form.md @@ -143,6 +143,59 @@ becomes `{ items: [{ productId: "value" }] }`), so nested values arrive in the s schema expects. `normalizeFormValues` recursively converts empty strings to `undefined` before validation runs. +## Posting the validated payload through a typed mutation + +Validation gives you a typed `result.data`; the write that follows should be typed +too. Instead of a hand-written `fetch`, run the mutation through the same +[typed SDK client](/services-sdk/sdk/) the rest of the app uses — the contract that +typed the form's schema is the contract that types the write, so the two cannot +drift. On the server, the handler calls the typed client directly with the +validated data: + +```ts +// apps/dashboard/routes/contact.ts (handler excerpt) +import { contactsClient } from "../lib/api-clients.ts"; + +// `result.data` is the contract-typed ContactValues from adapter.safeParse. +const created = await contactsClient.create(result.data); +// Redirect to the new record; the id is inferred from the contract output. +return Response.redirect(`/contact/${created.id}`, 303); +``` + +When the form is progressively enhanced with an island, drive the identical +contract write from the client with `useIslandMutation` and the query utils' +`mutationOptions()` — the mirror of the read path in +[Data loading and the query cache](/web-layer/query/). The validated values become +the typed mutation input: + +```tsx +// apps/dashboard/islands/ContactFormIsland.tsx +import { useIslandMutation } from "@netscript/fresh/query"; +import { contacts } from "../lib/api-clients.ts"; + +interface ContactValues { + email: string; + message: string; +} + +function useContactSubmit(onDone: () => void) { + // mutationOptions() supplies the contract-bound mutationFn + mutationKey. + return useIslandMutation({ + ...contacts.create.mutationOptions(), + onSuccess: onDone, + }); +} + +// After client-side validation succeeds, post the typed payload: +// submit.mutate(validated); // validated: ContactValues, checked at compile time +export { useContactSubmit, type ContactValues }; +``` + +`contacts` is one entry in the single `lib/api-clients.ts` module described in the +[query cache page](/web-layer/query/): `createServiceClient` → `createServiceQueryUtils`. +Either path — server handler or enhanced island — writes through the contract, so +the form's read schema and its write can never fall out of sync. + ## CSRF protection The form surface carries a CSRF token between the GET that renders the form and the POST From a87910fa6446c262850b81eedc05cae5b5347d2e Mon Sep 17 00:00:00 2001 From: Rickylabs Date: Sun, 12 Jul 2026 00:04:00 +0200 Subject: [PATCH 3/4] docs(web-layer): tie interactive/examples/testing to the typed surface interactive.md and examples.md each gain a typed route reference + QueryIsland + contract-derived queryOptions() example; testing.md gains Deno.tests exercising a bound route contract's parseSearch/safeParsePath. Proposal #13. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VifzAytcxBk5odEMaCXihM --- docs/site/web-layer/examples.md | 46 +++++++++++++++++++++++++ docs/site/web-layer/interactive.md | 54 ++++++++++++++++++++++++++++++ docs/site/web-layer/testing.md | 45 +++++++++++++++++++++++++ 3 files changed, 145 insertions(+) diff --git a/docs/site/web-layer/examples.md b/docs/site/web-layer/examples.md index 91f3aed9d..6e3d13b00 100644 --- a/docs/site/web-layer/examples.md +++ b/docs/site/web-layer/examples.md @@ -25,6 +25,52 @@ together. From there, the Web Layer how-to guides isolate each capability so you can lift a single pattern into your own project. +## The smallest typed example, end to end + +The shortest path from a contract to a rendered island: a typed route reference, +a contract-derived query, and a `QueryIsland`. It composes three documented +surfaces — [routing](/web-layer/route/), the [query cache](/web-layer/query/), and +the [SDK bridge](/services-sdk/sdk/) — and nothing in it is hand-typed: + +```tsx +// apps/dashboard/islands/OrdersPanel.tsx +import { QueryIsland, useIslandQuery } from "@netscript/fresh/query"; +import { createRouteReference } from "@netscript/fresh/route"; +import { orders } from "../lib/api-clients.ts"; // createServiceQueryUtils(ordersClient) + +const ordersRoute = createRouteReference("/orders"); + +function OrdersList() { + const query = useIslandQuery({ + ...orders.list.queryOptions({ input: { limit: 20 } }), + staleTime: 15_000, + }); + + return ( +
+ Orders +
    + {query.data?.map((order) =>
  • {order.reference}
  • )} +
+
+ ); +} + +export default function OrdersPanel() { + return ( + + + + ); +} +``` + +The `orders` query utils come from the single `lib/api-clients.ts` module the +[query cache page](/web-layer/query/) builds — `createServiceClient` → +`createServiceQueryUtils` — so a renamed contract field is a compile error here, +not a runtime blank. For the full read/write pair (typed query plus optimistic +mutation) see that page. + ## The package surface `@netscript/fresh` keeps its capabilities on explicit subpaths. The package root diff --git a/docs/site/web-layer/interactive.md b/docs/site/web-layer/interactive.md index 6c5e1bff2..f1c484588 100644 --- a/docs/site/web-layer/interactive.md +++ b/docs/site/web-layer/interactive.md @@ -72,6 +72,60 @@ within a Suspense boundary that provides a fallback. Wire that boundary on the s [Deferred and streaming UI](/web-layer/defer-streaming-ui/). {{ /comp }} +## What makes a NetScript island distinct: the typed query island + +A vanilla Fresh island is just hydrated Preact — you bring your own data fetching. +A NetScript island earns its keep by hydrating **contract-typed** data: a typed +route reference names the endpoint, and a `QueryIsland` renders the result of a +contract-derived query. Nothing about the URL or the response shape is hand-typed, +so the island cannot drift from the service. + +The route reference comes from [`@netscript/fresh/route`](/web-layer/route/); the +query utils come from the single `lib/api-clients.ts` module described in +[Data loading and the query cache](/web-layer/query/): + +```tsx +// apps/dashboard/islands/WidgetPanel.tsx +import { QueryIsland, useIslandQuery } from "@netscript/fresh/query"; +import { createRouteReference } from "@netscript/fresh/route"; +import { widgets } from "../lib/api-clients.ts"; + +// A typed reference to the page this island lives on — hrefs are checked +// against the pattern, so a broken link is a compile error. +const widgetsRoute = createRouteReference("/widgets"); + +function WidgetList() { + // queryKey + queryFn are derived from the widgets contract. + const query = useIslandQuery({ + ...widgets.list.queryOptions({ input: {} }), + staleTime: 15_000, + }); + + if (query.isLoading) return

Loading…

; + + return ( +
+ All widgets +
    + {query.data?.map((widget) =>
  • {widget.name}
  • )} +
+
+ ); +} + +export default function WidgetPanel() { + return ( + + + + ); +} +``` + +The `usePromise`/`resolvedPromise` seam above and this typed query island are the +two ways interactivity enters a page: read an in-flight promise during render, or +hydrate a contract-typed query. Most data-driven islands use the second. + ## API summary | Symbol | Description | diff --git a/docs/site/web-layer/testing.md b/docs/site/web-layer/testing.md index 115dce9c9..6142543fe 100644 --- a/docs/site/web-layer/testing.md +++ b/docs/site/web-layer/testing.md @@ -76,6 +76,51 @@ The fixture's `url` is a `URL`, `req` is a `Request`, `params` is a that need to assert on cancellation can pass their own `signal` through the options and observe it on the returned context. +## Testing a bound route contract + +A [route contract](/web-layer/route/) owns the parsing of a route's path and +search params, so its `parsePath`/`parseSearch` (and the non-throwing +`safeParsePath`/`safeParseSearch`) are worth testing directly — no context fixture +needed. Bind the contract to a pattern, then assert on what it parses and what it +rejects. This is the typed alternative to testing hand-rolled `URLSearchParams` +juggling: the schema is the single source of truth, and the test pins it. + +```ts +import { assert, assertEquals } from "@std/assert"; +import { + defineRouteContract, + enumPathParamSchema, + paginationSearchSchema, +} from "@netscript/fresh/route"; + +const ordersContract = defineRouteContract({ + pathSchema: enumPathParamSchema("status", ["open", "closed"]), + searchSchema: paginationSearchSchema({ defaultLimit: 25 }), +}); +const ordersRoute = ordersContract.bind("/orders/[status]"); + +Deno.test("orders route parses pagination and derives offset", () => { + const search = ordersRoute.parseSearch(new URLSearchParams("page=3&limit=50")); + assertEquals(search.page, 3); + assertEquals(search.limit, 50); + assertEquals(search.offset, 100); // (page - 1) * limit +}); + +Deno.test("orders route rejects an out-of-enum status", () => { + const ok = ordersRoute.safeParsePath({ status: "open" }); + assert(ok.success); + assertEquals(ok.data.status, "open"); + + const bad = ordersRoute.safeParsePath({ status: "archived" }); + assertEquals(bad.success, false); // not in the ["open","closed"] enum +}); +``` + +`safeParsePath`/`safeParseSearch` return a `SchemaParseResult` — a +`{ success: true, data }` success or a `{ success: false, error? }` failure — so a +test can assert both the happy path and the rejection without a `try`/`catch`. The +throwing `parsePath`/`parseSearch` variants suit tests that expect valid input. + ## Testing layouts Layout tests read `layerData` from the context, a `Record` From a130ddd801829fc5b01c443f5ff17a21e0ff8e47 Mon Sep 17 00:00:00 2001 From: Rickylabs Date: Sun, 12 Jul 2026 00:04:00 +0200 Subject: [PATCH 4/4] docs(run): web-layer #660a worklog Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01VifzAytcxBk5odEMaCXihM --- .../runs/docs-660a-web-layer--opus/worklog.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .llm/runs/docs-660a-web-layer--opus/worklog.md diff --git a/.llm/runs/docs-660a-web-layer--opus/worklog.md b/.llm/runs/docs-660a-web-layer--opus/worklog.md new file mode 100644 index 000000000..09b451b52 --- /dev/null +++ b/.llm/runs/docs-660a-web-layer--opus/worklog.md @@ -0,0 +1,70 @@ +# Worklog — #660 slice A: web-layer pillar overhaul (Opus, docs lane) + +Branch `docs/660-web-layer-pillar` · base `955b4abf` (preflight passed: HEAD `955b4abf`, +`tutorials/chat/05-mcp.md` present) · run dir `.llm/runs/docs-660a-web-layer--opus`. + +Owned audit proposals: **#1, #2 (matrix-level only), #13, #14, #16** + the #660 web-layer coverage +matrix. (#2 pagination rewrite of a live-dashboard tutorial chapter is owned by another slice; this +slice records it in the matrix as out-of-scope.) + +## Grounding (verified, not assumed) + +- `deno doc` over `packages/fresh/src/application/route/mod.ts`, `.../query/mod.ts`, + `packages/sdk/src/query/mod.ts`, `packages/sdk/src/query-client/mod.ts`. +- Exact call shapes read from `packages/sdk/src/ports/service-query-utils.ts`: + - `queryOptions({ input, staleTime?, enabled?, queryKey? })` → `{ queryKey, queryFn }`. + - `mutationOptions({ onSuccess?, onError?, onSettled?, mutationKey? })` → `{ mutationFn, mutationKey }`; + `mutate` takes the raw typed procedure input (no `{ input }` wrapper). `onMutate` is NOT a + `mutationOptions` field, so the optimistic seam is added on the `useIslandMutation` call. + - `createServiceQueryUtils(client, { path })` — verified via source `@example`. + - `queryKey({ input })` helper exists for building the list key used in optimistic rollback. +- Reference apps: `netscript-start-ref/apps/frontend/lib/api-clients.ts` (single-file typed clients, + one `createServiceClient` per service, contracts from a shared alias) and the scaffold `routes` + registry seed (`createRouteReference(routePatterns.X.$route, { id, kind })`). No reference-app or + internal names appear in any doc text (public-docs law). + +## Pages touched — before / after + +| Page | Before | After | +| --- | --- | --- | +| `_plan/web-layer-coverage-matrix.md` | (did not exist) | New unpublished matrix: every shipped fresh/fresh-ui/sdk frontend capability × treatment, grounded in `deno doc` + both reference apps. | +| `web-layer/query.md` | 3 raw-`fetch` `queryFn`/`mutationFn` examples (widgets list, doc poll, todo toggle); never named the sdk bridge. | Added "From contract to island: the typed query chain" + single `lib/api-clients.ts` (`createServiceClient` → `createServiceQueryUtils`). All 3 examples now spread `queryOptions()`/`mutationOptions()` into `useIslandQuery`/`useIslandMutation`; optimistic rollback preserved via `queryKey({ input })`. One raw `fetch` retained under an explicit **"Endpoints without a NetScript contract"** heading (external API). Added SDK cross-link (card + inline). | +| `web-layer/form.md` | Write path was a `// Run the mutation` comment; forms never connected to typed mutations. | New "Posting the validated payload through a typed mutation": server handler calls the typed client (`contactsClient.create(result.data)`), plus an island `useIslandMutation({ ...contacts.create.mutationOptions() })` variant — mirrors query.md read path (proposal #14). | +| `web-layer/interactive.md` | Islands primer with only `usePromise`/`resolvedPromise`; no typed query/route tie-in. | New "What makes a NetScript island distinct: the typed query island" — `createRouteReference` + `QueryIsland` + contract-derived `queryOptions()` (proposal #13). | +| `web-layer/examples.md` | Index/cache-helper page; no runnable typed showcase. | New "The smallest typed example, end to end" — route reference + `QueryIsland` + `orders.list.queryOptions()` composing route/query/sdk (proposal #13). | +| `web-layer/testing.md` | Only `createMockRouteContext`/`createMockDeferPolicy`; no bound-contract parse test. | New "Testing a bound route contract" — `Deno.test`s exercising `parseSearch` (offset derivation) and `safeParsePath` (enum rejection) (proposal #13). | +| `services-sdk/sdk.md` | "hand-rolled fetch wrapper tax" sentence unlinked. | Linked to `/web-layer/query/` — bidirectional cross-link (proposal #16). | + +## Matrix highlights + +- Highest-impact fix landed: `query.md` no longer teaches raw `fetch` in `queryFn`/`mutationFn`; + the whole page now flows contract → client → query-utils → island hook, matching + `web-layer/fresh-ui.md` one page over. +- One honest escape hatch retained (external, contract-less endpoint) so the untyped fallback is + shown without being presented as the default. +- Read/write symmetry closed: form.md write path + interactive/examples/testing all now connect to + the typed route/query surface that distinguishes NetScript islands from vanilla Fresh islands. +- Out-of-scope-for-this-slice rows (tutorial showcase gaps: proposals #2–#6) recorded in the matrix + as owned by other #660 slices. + +## Validation evidence + +- Base preflight: HEAD `955b4abf…`, `tutorials/chat/05-mcp.md` present → PASS. +- Baseline `deno task verify` (pre-edit): built 512 files; **169 pages / 23450 internal links** all + resolve; 27 caveat markers / 22 pages resolve. Matches the ~169/~23.5k main-parity expectation. +- Post-edit `deno task verify`: **169 pages / 23464 internal links** all resolve (+14 from new + cross-links); 27 caveat markers / 22 pages resolve. GREEN. +- Public-docs grep `eis-chat|eischat|VIF|CSB|PR #|pull/[0-9]|dogfood|issues/[0-9]` over + `docs/site` (excluding `_plan/`): **0 hits**. +- Remaining raw `fetch(` in the touched pages: exactly one, the intentional external-endpoint + example under "Endpoints without a NetScript contract". + +## Notes / drift + +- Proposal #1 text names `createServiceQueryUtils`; `web-layer/fresh-ui.md` uses the sibling + `createQueryFactories` (KV-backed SWR). Both are shipped and correct. Followed the slice spec + (`createServiceQueryUtils`, the pure client→island bridge) and added a one-line note + cross-link + in query.md pointing to `createQueryFactories` in `services-sdk/sdk.md` for the cache-first + variant, so the two pages reinforce rather than contradict. +- Doc code blocks are not type-checked by `deno task verify`; every API used was verified against + `deno doc`/source signatures (see Grounding), not memory.