// The shared CONTRACT root: the normalized schema, the relationships the views join back over, the // normalization helpers, and the ISOMORPHIC mutators — one generator body per mutator, driven by // BOTH tiers. The browser drives each body synchronously (the optimistic prediction); the API server // drives the SAME body inside an authoritative transaction, rendering its ops to SQL // (server/app-api.ts). // // This module is the leaf of the contract DAG: it depends on nothing app-internal. The named root // queries AND the per-component SELECTIONS they compose are co-located with their components in // `src/components/*.queries.ts` (Relay-style co-location). Keeping the schema here, free of those // imports, is what keeps that graph acyclic. import { defineRelationships, newQueryBuilder, rel, shared } from "@rindle/client"; import type { IsoTx, MutationGen, MutatorCtx, Row } from "@rindle/client"; import type { ClientRegistry } from "@rindle/optimistic"; import { z } from "zod"; // The schema is GENERATED from migrations/*.sql into ./schema.gen.ts by `rindle schema gen` — `pnpm // dev` regenerates it on every migration change, so the DDL is the single source of truth. We import // the tables + `schema` here and re-export them below, keeping this contract root the one import for // app code. import { message, room, schema } from "./schema.gen.ts"; // --------------------------------------------------------------------------- tables (generated) export { message, room, schema }; /** One schema-bound query builder, shared by every co-located `*.queries.ts`. Each `q.` access * mints a fresh builder, so sharing the single instance is safe. */ export const q = newQueryBuilder(schema); // --------------------------------------------------------------- row types (schema-derived) export type Room = Row; export type Message = Row; // --------------------------------------------------------------- relationships (joins, declared once) // // The one join in this app, declared ONCE: a room's messages. The home page's live `countAs` and the // room view both spread it instead of restating the `roomId → id` correlation. export const rels = defineRelationships({ roomMessages: rel(room, message, { id: "roomId" }), }); // --------------------------------------------------------------------------- normalization export function normalizeName(name: string): string { return name.trim().replace(/\s+/g, " ").slice(0, 80); } export function normalizeBody(body: string): string { return body.trim().slice(0, 4000); } /** A login handle reduced to a stable slug, so the client's predicted author id matches the server's. */ export function normalizeSubject(raw: string): string { return raw.trim().replace(/\s+/g, "-").toLowerCase().slice(0, 40) || "anon"; } // --------------------------------------------------------------------------- mutator args // // One zod schema per mutator, co-located with its body via `shared(args, gen)`. The SERVER parses the // UNTRUSTED wire args through it (server/app-api.ts); BOTH tiers derive the arg TYPE from it // (`z.infer`). NB: the AUTHOR is NOT an arg — it is the acting principal (`ctx.user`), injected by // each tier's driver (the client's local handle for the prediction; the server's AUTHENTICATED // identity for the authoritative run), so it is unspoofable over the wire. export const createRoomArgs = z.object({ id: z.string(), name: z.string(), createdAt: z.number() }); export type CreateRoomArgs = z.infer; export const postMessageArgs = z.object({ id: z.string(), roomId: z.string(), body: z.string(), createdAt: z.number(), }); export type PostMessageArgs = z.infer; // --------------------------------------------------------------------------- mutators (ISOMORPHIC) // // ONE body per mutator, shared by both tiers. Each is a GENERATOR: it `yield`s logical write ops // (`yield tx.insert(...)`) instead of touching a database, so the SAME function runs synchronously // against the browser's wasm engine (the optimistic prediction) AND asynchronously against a live // transaction on the server, each op rendered to SQL. Deterministic + replayable: every value that // would otherwise come from the clock or a random source is passed in args (ids, timestamps) — the // client RE-INVOKES the body on every rebase. Normalization AND the room-exists guard (a `tx.row` // read) run INSIDE the body, so both tiers behave identically; the server layers only what it ALONE // can do on top (identity required, an async "spam" moderation rejection demo — server/app-api.ts). export const mutators = { createRoom: shared(createRoomArgs, function* (tx: IsoTx, a: CreateRoomArgs): MutationGen { const name = normalizeName(a.name); if (!name) return; // a no-op prediction is fine; the server's guard hard-rejects yield tx.insertIgnore("room", { id: a.id, name, createdAt: a.createdAt }); }), postMessage: shared(postMessageArgs, function* (tx: IsoTx, a: PostMessageArgs, ctx: MutatorCtx): MutationGen { const body = normalizeBody(a.body); if (!body) return; // The room-exists guard, ISOMORPHIC (no raw SQL): READ the room through the mutator so a message // never lands in a room that doesn't exist. Read-your-writes on BOTH tiers — the browser's local // engine for the optimistic prediction, the server's interactive transaction for the authoritative // run — so the client predicts the SAME guard the server enforces, and a post into a deleted room // snaps back. if (!(yield tx.row("room", { id: a.roomId }))) return; yield tx.insert("message", { id: a.id, roomId: a.roomId, author: normalizeSubject(ctx.user), body, createdAt: a.createdAt, }); }), } satisfies ClientRegistry;