Manual Recipes Bidirectional Partition

Bidirectional Partition

elements man recipes/bidirectional-partition Read as markdown

Some rows belong to two parties at once. A direct message has a sender and a recipient; a friendship has both members; a trade has a buyer and a seller. Either party should see the row in their feed. The pattern: one LiveTable on the table, opened on two partitions ({ senderId } and { recipientId }). A write notifies the table's one channel, and each app server sends the row to every view whose partition it matches, so one message reaches the sender's feed and the recipient's. The page template merges both views into one chronological list.

The recipe is direct messages. A user lands on /inbox and sees every message they sent or received, ordered by time, with new messages from either side appearing live.

Migration

Terminal
elements create migration 'add direct messages' -tables=users,directMessages
app/migrations/<timestamp>-add-direct-messages.migration.sql
-- add direct messages -- Auto-update updatedAt on row changes. create or replace function touchUpdatedAt() returns trigger language plpgsql as $$ begin new.updatedAt = now(); return new; end; $$; create table users ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), handle text not null unique, passwordHash text not null ); create trigger usersTouchUpdatedAt before update on users for each row execute function touchUpdatedAt(); create table directMessages ( id uuid primary key default uuidGenerateV7(), createdAt timestamptz not null default now(), updatedAt timestamptz not null default now(), senderId uuid not null references users(id) on delete cascade, recipientId uuid not null references users(id) on delete cascade, body text not null ); create index directMessagesSenderIdIdx on directMessages (senderId); create index directMessagesRecipientIdIdx on directMessages (recipientId); create trigger directMessagesTouchUpdatedAt before update on directMessages for each row execute function touchUpdatedAt();

The two Idx indexes back the partition selects each view runs on first paint.

Page setup

Terminal
elements create page inbox

The LiveTable, the DirectMessage interface, and the findUserId RPC are used only by the /inbox page, so they live in the page's template.ehtml. The route imports them from there, opens two partitions of the table for the current user, and hands both views to the template; the template merges them into one chronological list.

app/pages/inbox/template.ehtml, above the markup:

app/pages/inbox/template.ehtml
import "./style.css"; import { LiveTable, LiveView, ForbiddenError, sql, session } from "@elements/app"; export interface DirectMessage { id: string; createdAt: Date; senderId: string; recipientId: string; body: string; } export let directMessages: LiveTable<DirectMessage> = new LiveTable<DirectMessage>({ insert: (item) => { session.isLoggedInOrThrow(); if (item.senderId !== session.getOrThrow('userId')) { throw new ForbiddenError(); } return directMessages.insert(item); }, update: () => { throw new ForbiddenError(); }, delete: (item) => { session.isLoggedInOrThrow(); if (item.senderId !== session.getOrThrow('userId')) { throw new ForbiddenError(); } return directMessages.delete(item); }, }); /** @rpc */ export function findUserId(handle: string): string | null { return sql<{ id: string }>(` select id from users where handle = ${handle} `).first()?.id ?? null; }

One declaration backs both feeds. Its channel is the table's, direct_messages, and every view of it hears every write; the partition decides which rows each view's browser receives. The declaration carries an explicit LiveTable<DirectMessage> annotation because its own initializer refers to it.

insert requires a signed-in session (isLoggedInOrThrow throws AuthError otherwise), then accepts a row only when senderId is the calling user. delete makes the same two checks. update is refused outright. A user cannot put a message in someone else's inbox, cannot edit a message, and cannot delete one they received. directMessages.insert(item) inside the handler is the raw auto-SQL on the declaration; the handler only gates it.

An insert through sent notifies the table's channel once. The sender's { senderId } view matches the row, and so does the recipient's { recipientId } view on whichever app server holds it, so both feeds gain the message from one write. Each view receives it once.

findUserId is the lookup for the composer: type a handle, get the user id back, then sent.insert(...) with the resolved id.

app/pages/inbox/index.ts
import { Request, Response, session } from "@elements/app"; import inbox, { directMessages } from "./template"; export default function route(req: Request, res: Response) { session.isLoggedInOrThrow(); let myUserId = session.getOrThrow('userId'); return new inbox({ myUserId, sent: directMessages.view({ senderId: myUserId }), received: directMessages.view({ recipientId: myUserId }), }); }

The route opens two partitions for the current user. view({ senderId: myUserId }) is the messages this user sent; view({ recipientId: myUserId }) is the messages this user received. The template gets both views plus the user's own id for the "is this mine?" check. The route is the authorization: it only ever opens the caller's own partitions.

The rest of app/pages/inbox/template.ehtml, under the code above:

app/pages/inbox/template.ehtml
function merged(sent: LiveView<DirectMessage>, received: LiveView<DirectMessage>): DirectMessage[] { return [...sent, ...received].sort((a, b) => +a.createdAt - +b.createdAt); } function onSend( sent: LiveView<DirectMessage>, myUserId: string, recipientHandle: { value: string }, body: { value: string }, error: { value: string }, ) { let recipientId = findUserId(recipientHandle.value.trim()); if (!recipientId) { error.value = "no user with that handle"; return; } sent.insert({ senderId: myUserId, recipientId, body: body.value, }, () => { body.value = ""; recipientHandle.value = ""; error.value = ""; }); } <html class="page-inbox" ( myUserId: string, sent: LiveView<DirectMessage>, received: LiveView<DirectMessage>, private recipient: { value: string } = { value: "" }, private body: { value: string } = { value: "" }, private error: { value: string } = { value: "" }, )> <main class="page-shell"> <h1>inbox</h1> <ul class="thread"> <li e:for={m of merged(sent, received)} class={m.senderId === myUserId ? "out" : "in"}> <span class="direction">{m.senderId === myUserId ? "→" : "←"}</span> <span class="body">{m.body}</span> </li> <li e:if={sent.length === 0 && received.length === 0}>no messages yet.</li> </ul> <form onsubmit={() => onSend(sent, myUserId, recipient, body, error)}> <input type="text" value={recipient.value} placeholder="recipient handle" required> <input type="text" value={body.value} placeholder="message" required> <p e:if={error.value} class="error">{error.value}</p> <button type="submit">send</button> </form> </main> </html>

merged(sent, received) spreads both views into one array and sorts it by createdAt. It runs inside the loop's expression, and iterating a view takes a dependency, so a row entering either partition re-runs the merge. The e:for then reconciles the new array against what is on screen by id: the new row is inserted and the rows already there keep their DOM and their state.

The insert payload carries senderId explicitly even though the partition would fill it. The insert handler compares it to the session, and the optimistic row is painted from exactly this object.

Each rendered row class (out / in) is derived from m.senderId === myUserId. The arrow shows direction. The composer at the bottom uses findUserId to resolve the recipient's handle, then inserts through the sent view.

Routes

Register the page in index.ts:

index.ts
import inbox from "#app/pages/inbox"; // ... app.route("/inbox", inbox);

Notes

  • One write, two feeds. Each message belongs to two partitions, the sender's and the recipient's. The write notifies the table once, and every app server matches the row against its open views, so both partitions hear it. A message written outside the app, by a job, psql, or another service, reaches both feeds the same way with one trigger on directMessages that notifies channel_name('direct_messages'); see elements man recipes/live-from-sql.
  • Why two partitions. A single whole-table view with .filter((m) => m.senderId === me || m.recipientId === me) in the template would deliver every message in the database to every browser, because a whole-table view matches every row. A partition is a column equality, and bidirectional ownership is two of them, so it is two views.
  • Auth integrity in the handler. insert checks that senderId === session.getOrThrow('userId'), which stops a client from spoofing the sender. The recipient is unconstrained, which matches DM semantics (anyone can message anyone). Tighten with a sql lookup that throws ForbiddenError if you want only mutual contacts to be reachable.
  • Three-party rows. The same shape extends. A trade row with buyer, seller, and broker becomes three partitions of one LiveTable, and each write reaches all three. Storage and indexes scale linearly with the number of parties.
  • Deleting a message. A delete through sent reaches every view holding the message, the sender's and the recipient's, so both sides see the deletion immediately. The recipient cannot initiate the delete: the handler refuses a row whose senderId is not theirs.