Pages: queries and commands

A page reads with a query and writes with a command. Both name a facade — the address of a handler, imported from @fronds/facade — and an operation of that handler.

import { post } from '@fronds/facade';

const { items, total, hasMore, loading, error } = await useQuery(post, 'list');
const publish = useCommand(post, 'publish');
await publish.execute({ params: { id } });
// every mounted query on `post` reads again

What the import carries

@fronds/facade is written by the scan: one const per address, carrying the handler that answers there as a type. Compiled, post is { address: 'post' } — the handler is never imported, so no server code reaches the bundle.

The type is the page's contract: which operations exist, what each answers, what each can refuse. You write no row shape by hand.

publish.error?.code   // ErrorCode.CONFLICT | ErrorCode.FORBIDDEN | ErrorCode.NOT_FOUND

Those are the codes PostHandler.publish can throw, read from its code and the helpers it calls. A switch over them with a never default stops compiling the day the operation learns a new refusal.

The address is the handler's name less Handler: PostHandler answers at post, CheckoutHandler at checkout. If your page already has a variable called post, rename the import: import { post as posts } from '@fronds/facade'.

useQuery

useQuery(facade, op, input?, opts?)
// op:    an operation of the handler — anything else does not compile
// input: CallInput | () => CallInput      CallInput = { params?, query?, input? }
// opts:  { immediate?: boolean }          false → do not call on mount

It returns, all reactive:

KeyWhat it holds
datathe answer as it came — one row for findById
itemsthe rows of a list, whether the operation answers an array or a page
total, hasMorewhen the operation answers a page
loadingtrue while the call runs
errorthe FougereError, typed by what the operation can refuse
refreshread again by hand — rarely needed, see below

A function as input makes the query follow its arguments:

const { items } = await useQuery(post, 'searchByTitle',
  () => ({ input: { title: submitted.value } }),
  { immediate: false });

useCommand

useCommand(facade, op)
// → { execute(input?), loading, error }

A refusal is not thrown: execute answers null and puts the FougereError in error. A page that shows the error writes nothing else; a page that goes on checks the answer:

if (await remove.execute({ params: { id } })) navigateTo('/');

A command that succeeds makes every mounted query on the same facade read again. Publish a post, and the lists of posts on screen refresh.

What travels

The browser sends a JSON-RPC call to POST /_fougere/call — method: "post.list" — the same frame two processes exchange. While the page renders on the server, the same call runs in memory, with no request at all. The browser never sends the call's state: the server builds it from the request, session included. A command is never retried on its own.

From the server: invoke

invoke calls an operation from server code — a route, a scheduled task — without reactive state:

// server/routes/feed.xml.get.ts
import Post from '@fronds/blog/entities/Post.js';

export default defineEventHandler(async () => {
  const posts = await invoke<Post[]>(Post, 'list');

  return renderRss(posts);
});
invoke<T>(Entity, op, input?)            // by entity
invoke<T>({ address, op }, input?)       // by address
  • A frond in this process runs in memory; one with a remote in fronds: is called over the network. Your code is the same.
  • Inside a request, the current session goes with the call. Outside one, the state is empty.
  • A refusal is the same FougereError a page receives.

invoke calls operations, nothing else. A service, the Logger or a value come from the constructor.

Built with Fougere and Nuxt, hosted on GitHub Pages.