# Bidirectional Partition 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 ```bash Terminal elements create migration 'add direct messages' -tables=users,directMessages ``` ```sql app/migrations/-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 ```bash 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: ```ehtml 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 = new LiveTable({ 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` 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. ```ts 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: ```ehtml app/pages/inbox/template.ehtml function merged(sent: LiveView, received: LiveView): DirectMessage[] { return [...sent, ...received].sort((a, b) => +a.createdAt - +b.createdAt); } function onSend( sent: LiveView, 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 = ""; }); } , received: LiveView, private recipient: { value: string } = { value: "" }, private body: { value: string } = { value: "" }, private error: { value: string } = { value: "" }, )>

inbox

  • {m.senderId === myUserId ? "→" : "←"} {m.body}
  • no messages yet.
onSend(sent, myUserId, recipient, body, error)}>

{error.value}

``` `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`: ```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.