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 — so the dashboard serves fast and
refreshes in the background.
- 1 · Scaffold
- 2 · Contract to service
- 3 · Cache-first query
- 4 · definePage + island
- 5 · Live stream
- 6 · Deploy
What you will build
A single module, apps/dashboard/lib/api-clients.ts, that exports a typed ordersClient and a
baseQueries.orders 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: the
orders service answering on 3002, the database seeded, and aspire start up. Confirm the typed
read-model still returns data:
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:
// apps/dashboard/lib/api-clients.ts
import { ordersContract } from '@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.
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:
// apps/dashboard/lib/api-clients.ts (add below the client)
// Server-side query factories — KV-backed stale-while-revalidate.
export const baseQueries = createQueryFactories({
orders: { contract: ordersContract, client: ordersClient },
});
// App code reads the per-procedure utilities off the factory.
export const ordersQueryUtils = baseQueries.orders;
baseQueries.orders 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:
| 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) => entry | undefined |
Server-side cache-first read: returns { data, cachedAt } from the KV cache, or undefined 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. |
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 ordersQueryUtils.list.getCachedEntry(input)inside a page loader. Returns{ data, cachedAt }from KV, orundefined. Used in chapter 4'sdefinePageloaders. - Client, in an island —
useQuery(ordersQueryUtils.list.queryOptions(input))inside a Fresh island. Used in chapter 4'sQueryIslandfor 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:
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/api-clients.tsexportsordersClient,baseQueries, andordersQueryUtils. - [ ]
deno task checkis clean. - [ ] You can name the four per-procedure helpers (
queryOptions,clientKey,getCachedEntry,key) and where each runs.
What you built
A typed ordersClient and a cache-first ordersQueryUtils 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.