This is me playing around. No claim that it's production ready, no claim that it's even a good idea. I'm building it because it's fun.
I don't think anyone has gotten data fetching right. Most of what we have is fine, none of it is great. Relay is the closest thing to perfection I've seen: the normalized cache, the fragment masking, the connection model, the way views co-locate with components and refetch in isolation. It's a different category from everything else.
But Relay drags GraphQL with it. The schema language, the compiler, the codegen pipeline, the directives, the persisted queries, the runtime that has to understand all of it. Every project that picks Relay is also picking a server stack, a build step, and a set of opinions that bleed into every layer. For a lot of apps that's too much.
I wanted something with the DX of Relay (the views, the masking, the typed store, the connection ergonomics, the refetchable slices, the typed mutations with optimistic updates) but without GraphQL. No SDL. No compiler. No codegen toolchain you have to keep in sync. Just TypeScript, any Standard Schema validator for your scalars (Zod, Valibot, ArkType, Effect Schema, …), plain resolver functions with a context object, a Vite plugin that generates resolver skeletons on dev start, and a normalized client cache that does the work.
Effect powers the internals (the handler pipeline, the typed error channel), but it never shows up at the surface. Your resolvers are ordinary functions; your types are ordinary Standard Schemas.
That's frame.
The examples import from
@/frame(client surface) and@/frame/server(server surface) — the path alias this repo uses forsrc/frame. Substitute your own package name if you lift it out.
pnpm install
pnpm devOn first start, the frame Vite plugin:
- Scans your schema files for types, queries, and mutations.
- Creates
src/routes/api/frame.ts(the HTTP endpoint) if it doesn't exist. - Generates resolver skeletons in
src/server/resolvers/(one file per type, one per query, one per mutation). - Generates a barrel
src/server/resolvers/index.gen.tsthat wires them together.
You fill in the resolver bodies. TypeScript tells you which fields you must implement.
Plain TypeScript. A Standard Schema validator for scalars, declarators for relationships. This demo uses Zod.
// src/server/schema/types.ts
import { z } from "zod";
import { type, scalar, ref, connection } from "@/frame";
export const User = type("User", {
id: scalar(z.string()),
name: scalar(z.string()),
avatarUrl: scalar(z.string()),
});
export const Post = type("Post", {
id: scalar(z.string()),
title: scalar(z.string()),
likes: scalar(z.number()),
author: ref(() => User), // single linked entity
comments: connection(() => Comment, {
// paginated, typed args
args: z.object({ sortBy: z.enum(["newest", "oldest"]) }),
}),
});scalar accepts any Standard Schema, so swap Zod for Valibot, ArkType, or Effect Schema (Schema.standardSchemaV1(...)) without frame caring.
Four field declarators:
scalar(schema): a primitive field. Type comes from the schema's inferred output.ref(() => T): a single linked entity.list(() => T): a plain (non-paginated) list of entities.connection(() => T, { args }): a paginated list. Always requires a resolver, never readable as a plain list.
Plus a top-level declarator for sum types:
union(name, () => [T1, T2, ...]): an algebraic union of FrameTypes. The resolver returns objects tagged with__typenameand the runtime dispatches per item. See "Union types" below.
Queries:
// src/server/schema/queries.ts
import { z } from "zod";
import { query } from "@/frame";
import { Post } from "./types";
export const postByIdQuery = query("postById", {
input: z.object({ id: z.string() }),
returns: Post,
});
export const feedQuery = query("feed", {
input: z.object({ category: z.string() }),
returns: Post,
list: true, // returns Post[] rather than a single Post
});Mutations look the same but return either a regular type or a transient payload (see Section 8).
// src/server/schema/mutations.ts
import { z } from "zod";
import { mutation } from "@/frame";
import { AddCommentPayload } from "./types";
export const addCommentMutation = mutation("addComment", {
input: z.object({ postId: z.string(), content: z.string() }),
returns: AddCommentPayload,
});On first dev start, the plugin writes typed skeletons. You fill in the body. Resolvers are plain functions — return a value or a Promise; frame awaits either. They receive a single argument with the inputs and a ctx you control.
// src/server/resolvers/Query/postById.ts
import { defineQuery } from "@/frame/server";
import { postByIdQuery } from "@/server/schema/queries";
export const postById = defineQuery(postByIdQuery, ({ input, ctx }) =>
ctx.db.postById(input.id), // input.id: string
);// src/server/resolvers/Post.ts (one file per type)
import { defineType } from "@/frame/server";
import { Post as PostType } from "@/server/schema/types";
export const Post = defineType(PostType)({
comments: async ({ parent, args, after, first, ctx }) => {
// parent: typed from the schema (title, likes, ...)
// args: { sortBy: "newest" | "oldest" }
// after: string | null
// first: number
return ctx.db.commentsPage(parent.id, args.sortBy, after, first);
// return shape: { edges, pageInfo }
},
});Two rules:
- Connections and fields with args always require a resolver. A default resolver can't produce a paginated shape or apply arguments.
- Everything else has a default: read
parent[field]. If your upstream resolver returned the field, the default just works. Override by defining a resolver explicitly.
So if Post.title is just parent.title, you don't need to write anything. If you want to override (a computed title, an enrichment, a translation), define it:
export const Post = defineType(PostType)({
// Override a scalar with a computed value.
title: ({ parent }) => `[${parent.category}] ${parent.title}`,
// Override a ref by fetching from a different source (async is fine).
author: ({ parent, ctx }) => ctx.users.byId(parent.authorId),
comments: ({ parent, args, after, first, ctx }) => /* ... */,
});The dispatch is resolver first, parent fallback: if a resolver is defined, it always runs. Otherwise frame reads parent[field].
Context. Every resolver gets ctx, typed by augmenting the FrameContext interface, and built once per request:
// src/server/context.ts
import { makeDb, type Db } from "./services/db";
declare module "@/frame/server" {
interface FrameContext {
db: Db;
}
}
/** Built per request, handed to every resolver as `ctx`. */
export const createContext = (request: Request) => ({
db: makeDb(),
});By default parent is the schema-derived shape: scalars typed from their schema, refs and lists optional, connections excluded. That's good enough for the common case where your upstream resolver returns roughly the schema shape.
When your upstream returns something more specific (a DB row with foreign keys, an API DTO with extra columns), declare a source:
type PostSource = {
id: string;
title: string;
excerpt: string;
content: string;
likes: number;
category: string;
createdAt: string;
authorId: string; // FK, not a nested User
};
export const Post = type("Post", {
/* schema fields, including author: ref(() => User) */
}).source<PostSource>();Two things change in defineType(PostType)({...}):
export const Post = defineType(PostType)({
// parent is now PostSource & { __typename: "Post" }
// parent.authorId autocompletes as string
// parent.foo (not in PostSource) is a TS error
// author is REQUIRED now (PostSource has authorId, not author)
author: ({ parent, ctx }) => ctx.users.byId(parent.authorId),
// comments is REQUIRED (connections always are)
comments: ({ parent, args, after, first, ctx }) => /* ... */,
// title, excerpt, likes, etc. stay OPTIONAL (PostSource covers them)
});The rule for "is this resolver required?":
- Connection → required.
- Field has args → required.
- Source declared and field name not in
keyof Source→ required. - Otherwise → optional.
Skip .source<T>() and frame uses the schema-derived default. Add it to tighten typing and surface forgotten resolvers at compile time.
Views are the client side of the schema. They're fragments: a typed declaration of the fields a component needs.
import { view } from "@/frame";
const UserCard = view(User, (u) => ({
name: u.name,
avatarUrl: u.avatarUrl,
}));
const PostPage = view(Post, (p) => ({
title: p.title,
likes: p.likes,
author: p.author.spread(UserCard),
}));One rule governs every kind of spread:
Named view → mask. You get back a
ViewKey<V>— an opaque handle to pass to a child component, which masks it withuseView. The parent can't read the child's fields.Inline builder → read here. The selected fields are inlined into this view's data and you read them directly.
That rule is the same whether you spread into a child entity, fold a sibling view into the same entity, or select a connection.
Field spreads — field.spread(...) selects fields inside a nested ref, list, or connection. The child view must be on the field's target type.
// Named → data.author is ViewKey<UserCard>; hand it to a child:
author: p.author.spread(UserCard),
// Inline → data.author is { name, avatarUrl }; read it right here:
author: p.author.spread((u) => ({ name: u.name, avatarUrl: u.avatarUrl })),Same-entity spreads — ...p.spread(...) folds another selection over the same type into this view. Use it to compose a child component's view into a parent's request so the whole page loads in one roundtrip.
const PostPage = view(Post, (p) => ({
title: p.title,
// Named → the read `data` is ALSO branded as ViewKey<PostMetaView>, so you
// can hand the whole `data` to the child component that owns that view:
...p.spread(PostMetaView),
// Inline → merge these fields straight into this view (a local helper select):
...p.spread((p) => ({ excerpt: p.excerpt, readingTime: p.readingTime })),
}));Connections — field(args).connection(...) applies the args, then selects the inner node shape and the page size.
view(Post, (p) => ({
// Named → each edge node is ViewKey<CommentRow>:
comments: p.comments({ sortBy: "newest" }).connection(CommentRow, { first: 10 }),
// Inline → each edge node is the read data { id, content }:
comments: p
.comments({ sortBy: "newest" })
.connection((c) => ({ id: c.id, content: c.content }), { first: 10 }),
}));A same-entity spread can be marked { defer: true }. The fragment is omitted from the initial fetch and loaded in a second, non-blocking roundtrip — the page renders immediately and the deferred part arrives out of band. Think GraphQL's @defer, but implemented as a separate request rather than a streamed multipart response.
export const PostDetailView = view(Post, (p) => ({
title: p.title,
content: p.content,
author: p.author.spread(UserAvatarView),
// Omitted from the eager fetch; fetched out of band right after.
...p.spread(PostCommentsView, { defer: true }),
}));A deferred spread:
- is left out of the eager wire — the first response doesn't pay for it;
- is fetched automatically in a follow-up roundtrip (also warmed by
prefetch, so a loader-primed page has it in flight before render); - makes the child suspend: the masked
datais branded so the child'suseView/usePaginationViewthrows until the deferred data lands. Wrap the child in a boundary:
function PostDetail({ viewRef }: { viewRef: ViewKey<typeof PostDetailView> }) {
const data = useView(PostDetailView, viewRef);
return (
<article>
<h1>{data.title}</h1>
<p>{data.content}</p>
{/* `data` is branded ViewKey<PostCommentsView> by the deferred spread */}
<Suspense fallback={<p>Loading comments…</p>}>
<CommentsSection viewRef={data} />
</Suspense>
</article>
);
}Eager is the default; opt a fragment out of the blocking path with { defer: true }.
Prop convention. A prop that carries a masked view handle is named
viewRefeverywhere in this codebase. Its type isViewKey<typeof SomeView>.
A union is a sum of FrameTypes. Use it when a single field or query can yield one of several concrete types, and you want the client to narrow per result. GraphQL calls these unions; Relay handles them with inline fragments.
Declare a union by listing its members:
// src/server/schema/types.ts
import { union } from "@/frame";
export const SearchResult = union("SearchResult", () => [Post, User]);Use it as a query return like any other type:
// src/server/schema/queries.ts
export const searchQuery = query("search", {
input: z.object({ q: z.string() }),
returns: SearchResult,
list: true,
});In the resolver, return objects tagged with __typename. Frame dispatches per item to the matching member type. Each member is normalized into the cache under its own Typename:id.
// src/server/resolvers/Query/search.ts
import { defineQuery } from "@/frame/server";
import { searchQuery } from "@/server/schema/queries";
export const search = defineQuery(searchQuery, ({ input, ctx }) => {
const q = input.q.toLowerCase();
const postHits = ctx.db
.posts()
.filter((p) => p.title.toLowerCase().includes(q))
.map((p) => ({ __typename: "Post" as const, ...p }));
const userHits = ctx.db
.users()
.filter((u) => u.name.toLowerCase().includes(q))
.map((u) => ({ __typename: "User" as const, ...u }));
return [...postHits, ...userHits];
});On the client, a view over a union is a record of typename to branch builder. Each branch's builder runs against that member's Builder<T>, so the fields autocomplete with the member's shape. Members you don't list are unselected (the reader still produces __typename for them, so you can branch on it in the UI).
const SearchResultView = view(SearchResult, {
Post: (p) => ({
id: p.id,
title: p.title,
excerpt: p.excerpt,
}),
User: (u) => ({
id: u.id,
name: u.name,
avatarUrl: u.avatarUrl,
}),
});useView returns a discriminated union. Narrow on __typename and TypeScript opens up the branch's fields:
function SearchHit({ viewRef }: { viewRef: ViewKey<typeof SearchResultView> }) {
const data = useView(SearchResultView, viewRef);
if (data.__typename === "Post") {
// data: { __typename: "Post"; id: string; title: string; excerpt: string }
return (
<li>
<h3>{data.title}</h3>
<p>{data.excerpt}</p>
</li>
);
}
if (data.__typename === "User") {
// data: { __typename: "User"; id: string; name: string; avatarUrl: string }
return (
<li>
<img src={data.avatarUrl} alt={data.name} />
{data.name}
</li>
);
}
return null;
}Inline form. If you don't need a named view (e.g. you don't pass the masked key to a child component), declare the branches directly at the pick() call. pick accepts a View, an inline concrete builder, or an inline branches record, and constructs the view for you.
export const SearchPageRequest = request("SearchPage", {
args: z.object({ q: z.string() }),
select: (args) => ({
results: pick(searchQuery, { q: args.q }, {
Post: (p) => ({ id: p.id, title: p.title, excerpt: p.excerpt }),
User: (u) => ({ id: u.id, name: u.name, avatarUrl: u.avatarUrl }),
}),
}),
});Use a named view(SearchResult, {...}) when you want to reuse the same selection across call sites or pass ViewKey<typeof TheView> to a child. The inline form is purely a convenience, identical at runtime.
Behind the scenes:
- The wire format ships either a flat selection (concrete view) or a
UnionWirewith per-typename branches (union view). The server handler dispatches on__typenameto pick the right branch when normalizing. - Each union member is a regular
FrameType, so members remain individually cacheable, queryable, and updatable. Mutating a Post that came back from asearchupdates every view currently watching that Post, regardless of how it was fetched.
Union-typed fields. Unions also work as the target of a field: ref(() => SomeUnion), list(() => SomeUnion), or connection(() => SomeUnion, { args }). The builder's .spread() / .connection() on such a field accept either a named View or an inline branches record, same as above.
export const Article = type("Article", {
id: scalar(z.string()),
title: scalar(z.string()),
blocks: list(() => Block), // Block = union of Heading | Paragraph
});
const ArticleView = view(Article, (a) => ({
title: a.title,
blocks: a.blocks.spread({
Heading: (h) => ({ id: h.id, level: h.level, text: h.text }),
Paragraph: (p) => ({ id: p.id, text: p.text }),
}),
}));Each element of blocks normalizes into the cache under its own member typename, and the read data is a discriminated union per item.
Caveat: cyclic schemas. A union-typed field pointing back to a type that's a member of the union (e.g. Post.related: list(() => SearchResult) where SearchResult = Post | User) creates a cyclic type instantiation TypeScript bails on after its depth limit. The runtime handles it fine; the type level may surface any/unknown there. Avoid the cycle with intermediate types, or accept the loss of typing on that field.
A request pairs an input schema with a selection over your queries. Each entry uses pick(query, args, view).
import { request, pick } from "@/frame";
export const PostPageRequest = request("PostPage", {
args: z.object({ id: z.string() }),
select: (args) => ({
post: pick(postByIdQuery, { id: args.id }, PostDetailView),
}),
});Reading in a component:
function PostPage({ id }: { id: string }) {
const { data, isStale, refetch } = useRequest(PostPageRequest, { id });
// data.post: ViewKey<typeof PostDetailView> (because `pick` got a named view)
return data.post ? <PostDetail viewRef={data.post} /> : <p>Not found.</p>;
}
pickwith a named view → the slot is aViewKey(mask, hand to a child).pickwith an inline builder/branches → the slot is read data. Same name=mask / inline=read rule as views.
A refetchable view declares its own slice of the data and re-fetches it in isolation. usePaginationView does this for connections; useRefetchableView does it for anything else (re-run with new variables). Mark the view { refetchable: true }.
// Co-located with the Comments component.
export const PostCommentsView = view(
Post,
(p) => ({
id: p.id,
comments: p
.comments({ sortBy: "newest" })
.connection(CommentRow, { first: 10 }),
}),
{ name: "PostCommentsView", refetchable: true },
);
function CommentsSection({ viewRef }: { viewRef: ViewKey<typeof PostCommentsView> }) {
const { data, loadNext, hasNext, isLoadingNext } = usePaginationView(PostCommentsView, viewRef);
return (
<>
<ul>
{data.comments.edges.map(({ node }, i) => (
<CommentRow key={i} viewRef={node} />
))}
</ul>
{hasNext && (
<button disabled={isLoadingNext} onClick={() => loadNext(10)}>
Load more
</button>
)}
</>
);
}useRefetchableView (no pagination, just re-run with different variables):
const ProfileView = view(User, (u) => ({ name: u.name, email: u.email }), {
name: "ProfileView",
refetchable: true,
});
const ProfileRequest = request("Profile", {
args: z.object({ userId: z.string() }),
select: (args) => ({ user: pick(userByIdQuery, { id: args.userId }, ProfileView) }),
});
function Profile({ viewRef }: { viewRef: ViewKey<typeof ProfileView> }) {
const { data, refetch } = useRefetchableView(ProfileView, ProfileRequest, viewRef);
return (
<>
<h2>{data.name}</h2>
<button onClick={() => refetch({ userId: "another" })}>Switch user</button>
</>
);
}The hooks are constrained at the type level:
useRefetchableViewrequires a view declared with{ refetchable: true }. Pass a regular view → compile error.usePaginationViewadditionally requires the view's selection to contain a connection. Pass a refetchable view with no connection → compile error.
Same guarantee Relay gets from its compiler, via TypeScript inference instead of codegen.
useMutation(mutation, View) takes the mutation and a view over its return/payload — the view shapes the data you get back, and brands any p.spread(ChildView) inside it so writes flow into every reader. It returns [commit, state] where state is { data, isInFlight, error }.
Most mutations just return the updated entity. Frame normalizes it by id and every view watching that entity re-renders. No updater, no invalidation.
// schema
export const likePostMutation = mutation("likePost", {
input: z.object({ postId: z.string() }),
returns: Post, // returns the Post directly
});// server — plain function
import { defineMutation } from "@/frame/server";
export const likePost = defineMutation(likePostMutation, ({ input, ctx }) =>
ctx.db.like(input.postId), // returns the updated Post row
);// client
const PostView = view(Post, (p) => ({ id: p.id, likes: p.likes }));
const [likePost, { isInFlight }] = useMutation(likePostMutation, PostView);
await likePost({ input: { postId } });
// Server returns the updated Post. Frame writes it into the cache at
// `Post:${postId}`. Every reader of that Post re-renders with the new count.That's the whole loop. No refetchQueries, no manual update, no invalidateQueries. The cache is keyed by __typename:id; any record returned anywhere ends up in the right place.
When you need more — instant feedback, or inserting into a connection the server doesn't know you have cached — commit accepts:
await likePost({
input: { postId },
// Optimistic: a plain object merged into the cache immediately, reverted
// if the mutation fails. `optimisticTypename` defaults to the mutation's
// return type name; set it for payload mutations (see Section 8).
optimistic: { id: postId, likes: currentLikes + 1 },
// Declarative connection inserts — frame can't infer which sortBy pages you
// have cached, so name them. `from` is the payload field to insert.
connections: [
{ parent: viewRef, field: "comments", args: { sortBy: "newest" }, where: "prepend", from: "newComment" },
],
// Or, for anything the declarative form can't express, an imperative updater:
updater: (store, result) => {
const post = store.get(Post, postId);
post?.getConnection("comments", { sortBy: "newest" })?.insert(result.newComment, { at: "start" });
},
});ConnectionInsert: { parent: ViewKey | string, field: string, args?, where: "prepend" | "append", from?: string }.
Some mutations need to return more than a single entity. The classic case: "I added a comment, but also bumped the post's commentCount." You want all of it to flow back so the cache stays consistent, but the shape itself isn't an entity you'd ever fetch directly.
That's a transient payload — declared with payload() instead of type(). The payload itself isn't normalized; every entity inside it is.
// src/server/schema/mutations.ts
import { payload, ref } from "@/frame";
export const AddCommentPayload = payload("AddCommentPayload", {
newComment: ref(() => Comment),
post: ref(() => Post), // updated post (e.g. new commentCount)
});
export const addCommentMutation = mutation("addComment", {
input: z.object({ postId: z.string(), content: z.string() }),
returns: AddCommentPayload,
});// src/server/resolvers/Mutation/addComment.ts
import { defineMutation } from "@/frame/server";
export const addComment = defineMutation(addCommentMutation, ({ input, ctx }) => {
const newComment = ctx.db.addComment(input.postId, input.content);
const post = ctx.db.postById(input.postId);
return { newComment, post };
// Frame normalizes `newComment` into Comment:<id> and `post` into Post:<id>.
// The payload wrapper itself is discarded after normalization.
});On the client, view the payload, then drive the cache with connections (declarative) or updater (imperative):
const AddCommentResult = view(AddCommentPayload, (p) => ({
newComment: p.newComment.spread(CommentView),
post: p.post.spread(PostCardView),
}));
const [addComment, { isInFlight }] = useMutation(addCommentMutation, AddCommentResult);
await addComment({
input: { postId, content },
optimistic: { content, createdAt: new Date().toISOString(), author: { __ref: "User:u1" } },
optimisticTypename: "Comment", // the payload has no single entity type, so name it
connections: [
{ parent: viewRef, field: "comments", args: { sortBy: "newest" }, where: "prepend", from: "newComment" },
],
});Use a regular type() when the mutation returns a single entity you'd also fetch elsewhere. Use payload() when the return is a structural wrapper holding multiple updated entities.
To write to the cache outside a mutation (a WebSocket event, a form change, a route transition), use commitLocalUpdate:
import { commitLocalUpdate, useFrameClient } from "@/frame";
function MarkRead({ postId }: { postId: string }) {
const client = useFrameClient();
return (
<button
onClick={() =>
commitLocalUpdate(client, (store) => {
store.get(Post, postId)?.setValue("isRead", true);
})
}
>
Mark read
</button>
);
}Or read the store directly inside a component with useStore:
const store = useStore();
const post = store.get(Post, postId);The store proxies are typed: RecordProxy<Post> knows what fields Post has and what kinds they are. getValue("title") returns string. getLink("author") returns a typed RecordProxy<User>. getConnection("comments", { sortBy: "newest" }) returns a ConnectionProxy<Comment> with insert, remove, replace, etc.
useRequest accepts:
useRequest(
PostPageRequest,
{ id },
{
fetchPolicy: "cache-and-network", // default; also: cache-first, network-only, cache-only
staleTime: 30_000, // ms; default 0 (always revalidated on mount under cache-and-network)
refetchInterval: 5_000, // ms; off by default
},
);Programmatic equivalents (no hook, use anywhere). They default to the ambient client, or take one via opts:
import { prefetch, fetchRequest, commitMutation } from "@/frame";
await prefetch(PostPageRequest, { id: "p1" }); // warm the cache (also fires deferred fragments)
const data = await fetchRequest(PostPageRequest, { id: "p1" }); // typed result
await commitMutation(likePostMutation, PostView, { input: { postId: "p1" } });prefetch is the loader companion — prime the cache before render and the component mounts without suspending, with any deferred fragments already in flight:
// a route loader
loader: ({ params }) => prefetch(PostPageRequest, { id: params.id }),- Schema discovery. The Vite plugin loads your schema files in dev and collects everything exported that looks like a frame type, query, mutation, or union.
- Code generation. Resolver skeletons are written once. The barrel
index.gen.tsis regenerated every dev start. The API route atsrc/routes/api/frame.tsis created once and you own it after that. - Wire protocol. Views compile to a flat selection tree (
compileView). Deferred spreads are omitted from the eager selection and shipped in a follow-up op. The handler walks the selection, runs resolvers (Effect internally), normalizes results into a record map keyed by__typename:id, and returns refs. - Normalized cache.
FrameCacheholds records, request roots, per-root staleness metadata, and a registry of in-flight deferred fetches. Mutations and pagination writes update the same map; subscribers are notified viauseSyncExternalStore. - View masking. A
ViewKey<V>is an opaque branded handle. The reader (unmask) takes a key and a view, walks the selection, reads from the cache, and returns the typedData<V>— inline spreads read through, named spreads emit childViewKeys, deferred spreads brand the key so the child suspends.
Playground. The shape of the API is what I'm exploring. Internals will change without warning. If you want to poke around, start with:
src/frame/core/type.ts: schema declarators and.source<T>().src/frame/core/view.ts: views, builders, selection nodes, spread/connection/defer.src/frame/core/request.ts:requestandpick.src/frame/core/data.ts: theData<V>mask-shape type.src/frame/client/hooks.ts:useRequest,useView,useRefetchableView,usePaginationView,useMutation,useStore.src/frame/client/cache.ts: normalized cache, staleness, deferred registry.src/frame/client/fetch.ts:prefetch,fetchRequest,commitMutation, deferred fetches.src/frame/client/unmask.ts: the cache reader / view masking.src/frame/client/store-proxy.ts: typedStoreProxy/RecordProxy/ConnectionProxy.src/frame/server/resolver.ts:defineType,defineQuery,defineMutation, source inference.src/frame/server/handler.ts: request execution and normalization.src/frame/vite-plugin.ts: the dev-time scaffolder.src/server/schema/andsrc/server/resolvers/: the demo app.