HighX

Walkthrough

Build a live note app end to end — schema, query, mutation, and a React UI that updates itself.

Everything below is real code you can paste into a project from getting started. At the end you will have a note app that two browsers share.

The finished version — with sign-in, presence, and conflict handling — is example/note in the repository.

1. Describe the data

A schema declares tables and the indexes queries may use. Indexes are part of the contract, not a performance afterthought: the reactive engine uses them to work out who to notify.

highx/functions.ts
import { defineSchema, defineTable, schema } from "@highx/core";

const noteRow = schema((input: unknown): Record<string, unknown> => {
  if (typeof input !== "object" || input === null) throw new Error("Expected a note");
  return { ...input };
});

export const databaseSchema = defineSchema({
  notes: defineTable("notes", noteRow, [
    { name: "byCreatedAt", fields: ["createdAt"] },
    { name: "byColor", fields: ["color", "createdAt"] },
  ]),
});

Every row gets an id automatically. → Schema

2. Read with a query

highx/functions.ts
import { objectSchema, query } from "@highx/core";

export const listNotes = query({
  name: "notes.list",
  args: emptyArgs,
  returns: noteRows,
  auth: true,
  handler: (ctx) => ctx.db.query("notes").withIndex("byCreatedAt").order("desc").take(50),
});

A query is read-only and deterministic — no fetch, no writes, no clock — which is what makes it safe to re-run automatically. → Queries

3. Write with a mutation

highx/functions.ts
import { mutation } from "@highx/core";

export const saveNote = mutation({
  name: "notes.save",
  args: noteArgs,
  returns: noteId,
  auth: can("notes:write"),
  handler: (ctx, args) => ctx.db.table("notes").insert({
    ...args,
    authorId: ctx.identity.userId,
    createdAt: ctx.now(),
  }),
});

The handler is one transaction: throw, and nothing is written. ctx.identity is the verified caller — never take a user or tenant ID from arguments. → Mutations

4. Regenerate

bunx highx generate

This writes api, a typed object of your function paths. Exporting a descriptor is what exposes it; a function you don't export can't be called from a browser.

5. Show it in React

src/main.tsx
import { createRoot } from "react-dom/client";
import { HighXProvider } from "@highx/react";
import { createBrowserTransport } from "@highx/client";
import { Notes } from "./notes.js";

// One connection for the whole app. Created outside any component, so a
// re-render cannot open a second socket.
const transport = createBrowserTransport("wss://localhost:8787/ws");

createRoot(document.getElementById("root")!).render(
  <HighXProvider cache={transport.cache} transport={transport}>
    <Notes />
  </HighXProvider>,
);
src/notes.tsx
import { useMutation, useQuery } from "@highx/react";
import { api } from "../.highx/api.js";

export function Notes() {
  const notes = useQuery(api.notes.list, {});
  const save = useMutation(api.notes.save);

  if (notes.isLoading) return <p>Loading…</p>;

  return (
    <>
      <button onClick={() => save({ title: "New", text: "…", color: "amber" })}>
        Add note
      </button>
      <ul>
        {(notes.data ?? []).map((note) => (
          <li key={String(note["id"])}>{String(note["title"])}</li>
        ))}
      </ul>
    </>
  );
}

Open it in two tabs. Adding a note in one updates the other. There is no refetch to call and no useEffect to write. → React

Give the provider the transport's own cache, as above. Two caches means patches arrive in one while components read the other — the UI loads correctly and then never updates again.

6. Reach the outside world

A mutation cannot call an API; that is what makes it a transaction. An action can, and composes mutations through ctx.run:

highx/functions.ts
export const summarize = action({
  name: "notes.summarize",
  args: idArgs,
  returns: summary,
  auth: can("notes:write"),
  handler: async (ctx, args) => {
    const note = ctx.db.table("notes").get(args.id);
    const response = await ctx.fetch("https://api.example.com/summarize", {
      method: "POST",
      body: JSON.stringify({ text: note?.["text"] }),
    });
    return ctx.run(saveSummary, { id: args.id, summary: await response.json() });
  },
});

→ Actions

7. React to changes in the background

To run something whenever a set of rows changes — not on a schedule, and not by polling:

highx/functions.ts
export const onUrgent = trigger({
  name: "notes.onUrgent",
  watch: { table: "notes", index: "byColor", equals: { color: "red" } },
  handler: async (ctx, event) => {
    for (const note of event.entered) await notifyOnCall(ctx, note);
  },
});

→ Triggers

Where to go next

On this page