Skip to content

ValenCassa/frame

Repository files navigation

frame

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.

Why

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 for src/frame. Substitute your own package name if you lift it out.


Quick start

pnpm install
pnpm dev

On first start, the frame Vite plugin:

  1. Scans your schema files for types, queries, and mutations.
  2. Creates src/routes/api/frame.ts (the HTTP endpoint) if it doesn't exist.
  3. Generates resolver skeletons in src/server/resolvers/ (one file per type, one per query, one per mutation).
  4. Generates a barrel src/server/resolvers/index.gen.ts that wires them together.

You fill in the resolver bodies. TypeScript tells you which fields you must implement.


Core concepts

1. Schema

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 __typename and 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,
});

2. Resolvers

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(),
});

3. Source types (typing parent)

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?":

  1. Connection → required.
  2. Field has args → required.
  3. Source declared and field name not in keyof Source → required.
  4. Otherwise → optional.

Skip .source<T>() and frame uses the schema-derived default. Add it to tighten typing and surface forgotten resolvers at compile time.

4. Views

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 with useView. 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 spreadsfield.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 })),
}));

Connectionsfield(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 }),
}));

Deferred fragments (@defer)

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 data is branded so the child's useView / usePaginationView throws 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 viewRef everywhere in this codebase. Its type is ViewKey<typeof SomeView>.

5. Union types

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 UnionWire with per-typename branches (union view). The server handler dispatches on __typename to 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 a search updates 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.

6. Reading data

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>;
}

pick with a named view → the slot is a ViewKey (mask, hand to a child). pick with 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:

  • useRefetchableView requires a view declared with { refetchable: true }. Pass a regular view → compile error.
  • usePaginationView additionally 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.

7. Mutations

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 }.

8. Transient payload types

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.

9. Imperative store edits

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.

10. Fetch policies and staleness

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 }),

How it works under the hood

  • 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.ts is regenerated every dev start. The API route at src/routes/api/frame.ts is 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. FrameCache holds 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 via useSyncExternalStore.
  • 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 typed Data<V> — inline spreads read through, named spreads emit child ViewKeys, deferred spreads brand the key so the child suspends.

Status

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: request and pick.
  • src/frame/core/data.ts: the Data<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: typed StoreProxy / 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/ and src/server/resolvers/: the demo app.

About

No description, website, or topics provided.

Resources

Stars

3 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors