# SDK client and cache-first query

In chapter 2 you served an `orders.list` read-model on port **3002**. Now you will read it from the Fresh app. The SDK gives you a typed client built from the same contract, and a query factory that wraps every procedure in a KV-backed stale-while-revalidate cache. For an operations screen that posture matters: the queue paints instantly from the last-known rows — even while the `orders` service is mid-redeploy — and refreshes in the background instead of blocking the person watching it.

1. [1 · Scaffold](https://rickylabs.github.io/netscript/netscript/tutorials/live-dashboard/01-scaffold/)
2. [2 · Contract to service](https://rickylabs.github.io/netscript/netscript/tutorials/live-dashboard/02-contract-to-service/)
3. [3 · Cache-first query](https://rickylabs.github.io/netscript/netscript/tutorials/live-dashboard/03-sdk-cache-first-query/)
4. [4 · definePage + island](https://rickylabs.github.io/netscript/netscript/tutorials/live-dashboard/04-definePage-QueryIsland/)
5. [5 · Live stream](https://rickylabs.github.io/netscript/netscript/tutorials/live-dashboard/05-live-stream/)
6. [6 · Deploy](https://rickylabs.github.io/netscript/netscript/tutorials/live-dashboard/06-deploy/)

## What you will build

A single module, `apps/dashboard/lib/orders.ts`, that exports a typed `ordersClient` and a `ordersQueries` query utility. The client is derived from `typeof ordersContract`, so calling `ordersClient.list({ … })` is type-checked against the contract you wrote in chapter 2. The query factory adds per-procedure helpers — `queryOptions()`, `clientKey()`, and the cache-first `getCachedEntry()` — that chapters 4 and 5 build the page on.

## Before you begin

You should have completed [chapter 2](https://rickylabs.github.io/netscript/tutorials/live-dashboard/02-contract-to-service/): the `orders` service answering on **3002**, the database seeded, and `aspire start` up. Confirm the typed read-model still returns data:

```sh
curl 'http://localhost:3002/api/v1/orders/list?limit=1&offset=0'
```

You should get one seeded order back. If `items` is empty, re-run `netscript db seed` from the workspace root.

## Step 1 — Create the typed service client

`createServiceClient` from `@netscript/sdk/client` builds a client from a contract. The key fields: the `contract` itself, and a `serviceName` that is the **discovery key** — how the client finds the service's URL at call time. Create the clients module in your Fresh app:

```ts
// apps/dashboard/lib/orders.ts
import { ordersContract } from '@my-dashboard/contracts';
import { createServiceClient } from '@netscript/sdk/client';
import { createQueryFactories } from '@netscript/sdk/query';

export const ordersClient = createServiceClient<typeof ordersContract>({
  contract: ordersContract,
  serviceName: 'orders',
});
```

`ordersClient.list(input)` now has the exact signature the contract declared — wrong input shape, or reading a field the output does not have, is a compile error.

> How serviceName resolves to a URL
>
> You never hardcode
>
> http://localhost:3002
>
> .
>
> serviceName: 'orders'
>
> is resolved at call time from an Aspire-injected env var — server-side
>
> services__orders__http__0
>
> , and the browser mirror
>
> VITE_services__orders__http__0
>
> — via
>
> getServiceUrl
>
> in
>
> @netscript/sdk/discovery
>
> . Aspire sets those when you list
>
> orders
>
> as a reference; the client just reads them. Full mechanics in
>
> Discover services
>
> .

## Step 2 — Add the cache-first query factory

A bare client calls the service every time. For a dashboard you want **cache-first**: serve the last-known answer instantly, then revalidate in the background. `createQueryFactories` wraps each procedure in exactly that — a KV-backed stale-while-revalidate layer. Add it to the same module:

```ts
// apps/dashboard/lib/orders.ts (add below the client)
// Server-side query factories — KV-backed stale-while-revalidate.
export const ordersQueries = createQueryFactories({
  orders: { contract: ordersContract, client: ordersClient },
}).orders;
```

`ordersQueries` carries one entry per contract procedure (`list`, `getById`, `getStats`, …), each a small object of typed helpers. The four you will use across the next chapters:

**Per-procedure query helpers (e.g. ordersQueries.list)**

| Name | Type | Description |
| --- | --- | --- |
| `.queryOptions(input)` | `(input) => options` | A TanStack Query options object (queryKey + queryFn) for the client island — chapter 4 passes it straight to useQuery. |
| `.clientKey(input?)` | `(input?) => key` | The stable query key the client uses to read, write, and invalidate this procedure's cache. |
| `.getCachedEntry(input)` | `(input) => Promise` | Server-side cache-first read: resolves to { data, cachedAt } from the KV cache, or null on a cold cache. This is the page loader's fast path. |
| `.key(input)` | `(input) => key` | The server-side KV cache key for the entry. |

> Why KV, and why cache-first
>
> The cache is backed by KV (Redis in your Aspire stack — the default
>
> --cache-backend
>
> , registered at the top of
>
> main.ts
>
> in chapter 1;
>
> garnet
>
> and
>
> deno-kv
>
> are alternatives). Cache-first means a page render does not block on the service:
>
> getCachedEntry
>
> returns immediately from KV when warm, and the stale entry refreshes in the background. A cold cache resolves to
>
> null
>
> , which the page handles with a skeleton — you wire that in chapter 4.

## Step 3 — Understand the calling shapes

You now have two ways to read orders, for two different places in the stack:

- **Server, cache-first** — `await ordersQueries.list.getCachedEntry(input)` inside a page loader. Resolves to `{ data, cachedAt }` from KV, or `null`. Used in chapter 4's `definePage` loaders.
- **Client, in an island** — `useQuery(ordersQueries.list.queryOptions(input))` inside a Fresh island. Used in chapter 4's `QueryIsland` for client-side reads and refetch.

Both derive their types and their cache key from the *same* contract, so a server-rendered row and a client-refetched row are guaranteed to be the same shape.

## Verify your progress

Type-check the workspace to prove the client and query factory line up with the contract:

```sh
deno task check
```

A clean check confirms `createServiceClient<typeof ordersContract>` and the query factory typed themselves off your chapter-2 contract. To prove the discovery key resolves end to end, leave `aspire start` up — the next chapter renders the page that calls through this client, and a missing `services__orders__http__0` shows up there as a clear "Service URL not found" error.

- [ ] `apps/dashboard/lib/orders.ts` exports `ordersClient` and `ordersQueries`.
- [ ] `deno task check` is clean.
- [ ] `deno check apps/dashboard/lib/orders.ts --unstable-kv` passes on its own — the module compiles against the chapter-2 contract with no other file in play.

## What you built

A typed `ordersClient` and a cache-first `ordersQueries` query layer, both derived from the chapter-2 contract, with service discovery resolving the URL for you. Next you will render the live table: NetScript's `definePage` builder for the server shell, and a `QueryIsland` that hydrates these query helpers in the browser.

[2 · Contract to service](https://rickylabs.github.io/netscript/netscript/tutorials/live-dashboard/02-contract-to-service/) [4 · definePage + island](https://rickylabs.github.io/netscript/netscript/tutorials/live-dashboard/04-definePage-QueryIsland/)
