Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .llm/runs/docs-660a-web-layer--opus/worklog.md
Original file line number Diff line number Diff line change
@@ -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.
92 changes: 92 additions & 0 deletions docs/site/_plan/web-layer-coverage-matrix.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 3 additions & 3 deletions docs/site/services-sdk/sdk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 46 additions & 0 deletions docs/site/web-layer/examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<section>
<a href={ordersRoute.href()}>Orders</a>
<ul>
{query.data?.map((order) => <li key={order.id}>{order.reference}</li>)}
</ul>
</section>
);
}

export default function OrdersPanel() {
return (
<QueryIsland>
<OrdersList />
</QueryIsland>
);
}
```

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
Expand Down
53 changes: 53 additions & 0 deletions docs/site/web-layer/form.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading