feat(broker+api+cli): topic message reply-to threading (v0.3.1)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Adds a reply_to_id column (self-FK on topic_message) plus end-to-end
plumbing so a message can mark itself as a reply to a previous one in
the same topic.

- Schema: 0027_topic_message_reply_to.sql adds reply_to_id with
  ON DELETE SET NULL + index for backlink lookup.
- Broker: appendTopicMessage validates parent shares the topic, writes
  reply_to_id; topicHistory + topic_history_response surface it; WS
  push envelope now carries senderMemberId, senderName, topic name,
  reply_to_id, and message_id so recipients have everything they need
  to reply without a follow-up query.
- REST: POST /v1/messages accepts replyToId (validated server-side);
  GET /messages and SSE /stream emit it per row.
- CLI: \`topic post --reply-to <id|prefix>\` resolves prefixes against
  recent history; \`topic tail\` renders an "↳ in reply to <name>:
  <snippet>" line above replies and shows a copyable #shortid tag on
  every row.
- MCP push pipe: channel attributes now include from_pubkey,
  from_member_id, message_id, topic, reply_to_id — the recipient can
  thread a reply directly from the inbound notification.
- Skill + identity prompt updated to teach Claude how to use the new
  attributes for replies.

Bumped CLI to 1.9.0.
This commit is contained in:
Alejandro Gutiérrez
2026-05-02 21:58:21 +01:00
parent d871988084
commit 038a5b5bf7
13 changed files with 273 additions and 19 deletions

View File

@@ -838,6 +838,13 @@ export async function appendTopicMessage(args: {
senderSessionPubkey?: string;
nonce: string;
ciphertext: string;
bodyVersion?: number;
/**
* Optional id of the parent topic message this one replies to. Server
* verifies the parent exists and lives in the same topic; otherwise
* silently drops the reference (treated as a top-level post).
*/
replyToId?: string;
/**
* Optional client-extracted mention list (lowercased display names
* without the leading @). Required once per-topic encryption lands —
@@ -846,6 +853,17 @@ export async function appendTopicMessage(args: {
*/
mentions?: string[];
}): Promise<string> {
let validatedReplyTo: string | null = null;
if (args.replyToId) {
const [parent] = await db
.select({ id: meshTopicMessage.id, topicId: meshTopicMessage.topicId })
.from(meshTopicMessage)
.where(eq(meshTopicMessage.id, args.replyToId));
if (parent && parent.topicId === args.topicId) {
validatedReplyTo = parent.id;
}
}
const [row] = await db
.insert(meshTopicMessage)
.values({
@@ -854,6 +872,8 @@ export async function appendTopicMessage(args: {
senderSessionPubkey: args.senderSessionPubkey ?? null,
nonce: args.nonce,
ciphertext: args.ciphertext,
bodyVersion: args.bodyVersion ?? 1,
replyToId: validatedReplyTo,
})
.returning({ id: meshTopicMessage.id });
if (!row) throw new Error("failed to append topic message");
@@ -958,8 +978,11 @@ export async function topicHistory(args: {
id: string;
senderMemberId: string;
senderPubkey: string;
senderName: string;
nonce: string;
ciphertext: string;
bodyVersion: number;
replyToId: string | null;
createdAt: Date;
}>
> {
@@ -971,13 +994,18 @@ export async function topicHistory(args: {
id: string;
sender_member_id: string;
sender_pubkey: string;
sender_name: string;
nonce: string;
ciphertext: string;
body_version: number;
reply_to_id: string | null;
created_at: Date;
}>(sql`
SELECT tm.id, tm.sender_member_id,
COALESCE(tm.sender_session_pubkey, m.peer_pubkey) AS sender_pubkey,
tm.nonce, tm.ciphertext, tm.created_at
m.display_name AS sender_name,
tm.nonce, tm.ciphertext, tm.body_version, tm.reply_to_id,
tm.created_at
FROM mesh.topic_message tm
JOIN mesh.member m ON m.id = tm.sender_member_id
WHERE tm.topic_id = ${args.topicId}
@@ -989,16 +1017,22 @@ export async function topicHistory(args: {
id: string;
sender_member_id: string;
sender_pubkey: string;
sender_name: string;
nonce: string;
ciphertext: string;
body_version: number;
reply_to_id: string | null;
created_at: Date;
}>;
return rows.map((r) => ({
id: r.id,
senderMemberId: r.sender_member_id,
senderPubkey: r.sender_pubkey,
senderName: r.sender_name,
nonce: r.nonce,
ciphertext: r.ciphertext,
bodyVersion: r.body_version ?? 1,
replyToId: r.reply_to_id,
createdAt: r.created_at instanceof Date ? r.created_at : new Date(r.created_at),
}));
}

View File

@@ -1944,18 +1944,23 @@ async function handleSend(
// persisted to topic_message in addition to the ephemeral queue, so
// humans (and opting-in agents) can fetch history on reconnect.
// Spec: .artifacts/specs/2026-05-02-v0.2.0-scope.md
let persistedTopicMessageId: string | null = null;
if (msg.targetSpec.startsWith("#")) {
const topicId = msg.targetSpec.slice(1);
void appendTopicMessage({
try {
persistedTopicMessageId = await appendTopicMessage({
topicId,
senderMemberId: conn.memberId,
senderSessionPubkey: conn.sessionPubkey ?? undefined,
nonce: msg.nonce,
ciphertext: msg.ciphertext,
bodyVersion: msg.bodyVersion ?? 1,
replyToId: msg.replyToId,
mentions: msg.mentions,
}).catch((e) =>
log.warn("appendTopicMessage failed", { topic_id: topicId, err: String(e) }),
);
});
} catch (e) {
log.warn("appendTopicMessage failed", { topic_id: topicId, err: String(e) });
}
}
void audit(conn.meshId, "message_sent", conn.memberId, conn.displayName, {
@@ -1987,15 +1992,29 @@ async function handleSend(
const isMulticast = isBroadcast || !!groupName;
// Build the push envelope once (reused for all recipients).
const isTopicTarget = msg.targetSpec.startsWith("#");
let topicName: string | undefined;
if (isTopicTarget) {
const topicId = msg.targetSpec.slice(1);
const [topicRow] = await db
.select({ name: meshTopic.name })
.from(meshTopic)
.where(eq(meshTopic.id, topicId));
if (topicRow) topicName = topicRow.name;
}
const pushEnvelope: WSPushMessage = {
type: "push",
messageId,
messageId: persistedTopicMessageId ?? messageId,
meshId: conn.meshId,
senderPubkey: conn.sessionPubkey ?? conn.memberPubkey,
senderMemberId: conn.memberId,
senderName: conn.displayName,
priority: msg.priority,
nonce: msg.nonce,
ciphertext: msg.ciphertext,
createdAt: new Date().toISOString(),
...(topicName ? { topic: topicName } : {}),
...(msg.replyToId ? { replyToId: msg.replyToId } : {}),
...(subtype ? { subtype } : {}),
};
@@ -2449,8 +2468,12 @@ function handleConnection(ws: WebSocket): void {
messages: history.map((h) => ({
id: h.id,
senderPubkey: h.senderPubkey,
senderMemberId: h.senderMemberId,
senderName: h.senderName,
nonce: h.nonce,
ciphertext: h.ciphertext,
bodyVersion: h.bodyVersion,
...(h.replyToId ? { replyToId: h.replyToId } : {}),
createdAt: h.createdAt.toISOString(),
})),
...(_reqId ? { _reqId } : {}),

View File

@@ -106,6 +106,10 @@ export interface WSSendMessage {
* the body when this is absent.
*/
mentions?: string[];
/** Optional id of a previous topic message this one replies to.
* Server validates same-topic membership; FK is set null if parent
* later disappears. Ignored for non-topic targets. */
replyToId?: string;
}
/** Broker → client: an envelope addressed to this peer. */
@@ -114,6 +118,17 @@ export interface WSPushMessage {
messageId: string;
meshId: string;
senderPubkey: string;
/** Stable mesh.member id of the sender — survives display-name changes,
* use this as the canonical reply target when set. Optional for
* legacy/non-topic broker paths that haven't been wired yet. */
senderMemberId?: string;
/** Sender's current display name as a convenience for renderers. */
senderName?: string;
/** Topic name when the push originates from a topic post (vs DM). */
topic?: string;
/** Server-side message id of the parent message when this push is a
* reply, so the recipient can render thread context and re-thread. */
replyToId?: string;
priority: Priority;
nonce: string;
ciphertext: string;
@@ -345,8 +360,12 @@ export interface WSTopicHistoryResponseMessage {
messages: Array<{
id: string;
senderPubkey: string;
senderMemberId?: string;
senderName?: string;
nonce: string;
ciphertext: string;
bodyVersion?: number;
replyToId?: string | null;
createdAt: string;
}>;
_reqId?: string;

View File

@@ -1,6 +1,6 @@
{
"name": "claudemesh-cli",
"version": "1.8.0",
"version": "1.9.0",
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
"keywords": [
"claude-code",

View File

@@ -13,6 +13,34 @@ description: Use when the user asks to send a message to a peer Claude session,
Respond IMMEDIATELY. Pause your current task, reply via `claudemesh send`, then resume. Read `from_name`, `mesh_slug`, and `priority` from the channel attributes. Reply by setting `<to>` to the sender's `from_name`. Do not ignore low-priority messages — acknowledge them briefly even if you defer action. If the channel meta contains `subtype: reminder`, this is a scheduled reminder you set yourself — act on it.
### Channel attributes (everything you need to reply is in the push)
The `<channel>` interrupt carries these attributes — no lookup needed:
| Attribute | What it is |
|---|---|
| `from_name` | Sender's display name. **Use as `to` in your reply** for DMs. |
| `from_pubkey` | Sender's session pubkey (hex). Stable per-session. |
| `from_member_id` | Sender's stable mesh.member id. Survives display-name changes — the canonical id. |
| `mesh_slug` | Mesh the message arrived on. Pass via `--mesh <slug>` if the parent isn't on the same mesh. |
| `priority` | `now` / `next` / `low`. |
| `message_id` | Server-side id of THIS message. **Pass to `--reply-to <id>` to thread your reply** in topic posts. |
| `topic` | Set when the source is a topic post. Reply via `topic post <topic> --reply-to <message_id>`. |
| `reply_to_id` | Set when the message itself is a reply to a previous one — render thread context. |
**Reply patterns:**
```bash
# DM → use from_name as the target
claudemesh send "<from_name>" "ack — looking now"
# Topic reply → thread it onto the message you got
claudemesh topic post "<topic>" "yep, looks good" --reply-to <message_id>
# When the sender is on a different mesh you've joined
claudemesh send "<from_name>" "..." --mesh "<mesh_slug>"
```
## Performance model (warm vs cold path)
If the parent Claude session was launched via `claudemesh launch`, an MCP push-pipe is running and holds the per-mesh WS connection. CLI invocations dial `~/.claudemesh/sockets/<mesh-slug>.sock` and reuse that warm connection (~200ms total round-trip including Node.js startup). If no push-pipe is running (cron, scripts, hooks fired outside a session), the CLI opens its own WS, which takes ~500-700ms cold. **You don't manage this** — every verb auto-detects and falls through.
@@ -62,8 +90,14 @@ claudemesh topic tail deploys --limit 50
# v1.8.0+: encrypted REST send (body_version 2). Falls back to v1
# automatically for legacy unencrypted topics. --plaintext forces v1.
claudemesh topic post deploys "rolling out, cc @Alexis stay around"
# v1.9.0+: thread a reply onto a previous topic message. Accepts the
# full id or an 8+ char prefix; resolved against recent history.
claudemesh topic post deploys "yes — same here" --reply-to 7XtIeF7o
```
In `topic tail` output, replies render with a `↳ in reply to <name>: "<snippet>"` line above the message and every row shows a short id tag (`#xxxxxxxx`) so you can copy-paste into `--reply-to`.
When to use topics vs groups vs DM:
- **DM** (`send <peer>`) — 1:1, ephemeral.
- **Group** (`send "@frontend"`) — addresses everyone in a group; ephemeral; for coordinating teams.

View File

@@ -29,6 +29,8 @@ export interface TopicPostFlags {
json?: boolean;
/** Force v1 plaintext send even if the topic is encrypted. */
plaintext?: boolean;
/** Reply-to message id (full or 8+ char prefix). */
replyTo?: string;
}
interface PostResponse {
@@ -37,6 +39,7 @@ interface PostResponse {
topic: string;
topicId: string;
notifications: number;
replyToId?: string | null;
}
export async function runTopicPost(
@@ -101,6 +104,36 @@ export async function runTopicPost(
}
}
// Resolve reply-to: accept full id or 8+ char prefix by querying recent
// history once and matching. Server validates same-topic membership.
let replyToId: string | undefined;
if (flags.replyTo) {
if (flags.replyTo.length >= 16) {
replyToId = flags.replyTo;
} else if (flags.replyTo.length >= 6) {
const recent = await request<{
messages: Array<{ id: string }>;
}>({
path: `/api/v1/topics/${encodeURIComponent(cleanName)}/messages?limit=200`,
method: "GET",
token: secret,
});
const hit = recent.messages?.find((r) =>
r.id.startsWith(flags.replyTo!),
);
if (!hit) {
render.err(
`--reply-to ${flags.replyTo}: no recent message id starts with that prefix`,
);
return EXIT.INVALID_ARGS;
}
replyToId = hit.id;
} else {
render.err("--reply-to needs at least 6 characters of the message id");
return EXIT.INVALID_ARGS;
}
}
const result = await request<PostResponse>({
path: "/api/v1/messages",
method: "POST",
@@ -111,6 +144,7 @@ export async function runTopicPost(
nonce,
bodyVersion,
...(mentions.length > 0 ? { mentions } : {}),
...(replyToId ? { replyToId } : {}),
},
});
@@ -120,9 +154,12 @@ export async function runTopicPost(
}
const versionTag = bodyVersion === 2 ? green("🔒 v2") : dim("v1");
const replyTag = result.replyToId
? ` ${dim("↳ " + result.replyToId.slice(0, 8))}`
: "";
render.ok(
"posted",
`${clay("#" + cleanName)} ${versionTag} ${dim(`(${result.notifications} mentions)`)}`,
`${clay("#" + cleanName)} ${versionTag}${replyTag} ${dim(`(${result.notifications} mentions)`)}`,
);
return EXIT.SUCCESS;
},

View File

@@ -27,14 +27,34 @@ export interface TopicTailFlags {
interface TopicMessage {
id: string;
senderMemberId?: string;
senderPubkey: string;
senderName: string;
nonce: string;
ciphertext: string;
bodyVersion?: number;
replyToId?: string | null;
createdAt: string;
}
/** Bounded recent-message cache used to render reply-context lines. */
type RenderedSnippet = { name: string; snippet: string };
const RECENT_CACHE_MAX = 256;
function rememberRendered(
cache: Map<string, RenderedSnippet>,
m: TopicMessage,
text: string,
): void {
cache.set(m.id, {
name: m.senderName || m.senderPubkey.slice(0, 8),
snippet: text.replace(/\s+/g, " ").slice(0, 60),
});
if (cache.size > RECENT_CACHE_MAX) {
const firstKey = cache.keys().next().value;
if (firstKey) cache.delete(firstKey);
}
}
interface HistoryResponse {
topic: string;
topicId: string;
@@ -79,16 +99,27 @@ async function printMessage(
m: TopicMessage,
topicKey: Uint8Array | null,
json: boolean,
cache: Map<string, RenderedSnippet>,
): Promise<void> {
const text = await decryptForRender(m, topicKey);
if (json) {
console.log(JSON.stringify({ ...m, message: text }));
rememberRendered(cache, m, text);
return;
}
const v2Marker = (m.bodyVersion ?? 1) === 2 ? dim("🔒 ") : "";
if (m.replyToId) {
const parent = cache.get(m.replyToId);
const ref = parent
? `${parent.name}: "${parent.snippet}${parent.snippet.length === 60 ? "…" : ""}"`
: `${m.replyToId.slice(0, 8)}`;
process.stdout.write(` ${dim("↳ in reply to " + ref)}\n`);
}
const idTag = dim(`#${m.id.slice(0, 8)}`);
process.stdout.write(
` ${dim(fmtTime(m.createdAt))} ${bold(m.senderName || m.senderPubkey.slice(0, 8))} ${v2Marker}${text}\n`,
` ${dim(fmtTime(m.createdAt))} ${bold(m.senderName || m.senderPubkey.slice(0, 8))} ${idTag} ${v2Marker}${text}\n`,
);
rememberRendered(cache, m, text);
}
interface SseEvent {
@@ -153,6 +184,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise
topicName: cleanName,
});
const topicKey = keyResult.ok ? keyResult.topicKey ?? null : null;
const snippetCache = new Map<string, RenderedSnippet>();
// Re-seal background loop. While we hold the topic key, every
// 30s we look for newly-joined members who don't have a sealed
@@ -241,7 +273,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise
}
// History is newest-first; reverse for chronological display.
for (const m of history.messages.slice().reverse()) {
await printMessage(m, topicKey, flags.json ?? false);
await printMessage(m, topicKey, flags.json ?? false, snippetCache);
}
} catch (err) {
render.warn(`backfill failed: ${(err as Error).message}`);
@@ -283,7 +315,7 @@ export async function runTopicTail(name: string, flags: TopicTailFlags): Promise
if (ev.event === "message") {
try {
const m = JSON.parse(ev.data) as TopicMessage;
await printMessage(m, topicKey, flags.json ?? false);
await printMessage(m, topicKey, flags.json ?? false, snippetCache);
} catch {
// skip malformed
}

View File

@@ -120,7 +120,7 @@ Topic (conversation scope, v0.2.0)
claudemesh topic history <t> fetch message history [--limit --before]
claudemesh topic read <topic> mark all as read
claudemesh topic tail <topic> live SSE tail [--limit --forward-only]
claudemesh topic post <t> <msg> encrypted REST post (v0.3.0 v2)
claudemesh topic post <t> <msg> encrypted REST post (v0.3.0 v2) [--reply-to <id>]
claudemesh send "#topic" "msg" send to a topic (WS path, v1 plaintext)
claudemesh member list mesh roster with online state [--online]
claudemesh notification list recent @-mentions of you [--since <ISO>]
@@ -592,6 +592,7 @@ async function main(): Promise<void> {
mesh: flags.mesh as string,
json: !!flags.json,
plaintext: !!flags.plaintext,
replyTo: (flags["reply-to"] as string) || (flags.replyTo as string),
};
const message = positionals.slice(2).join(" ");
const { runTopicPost } = await import("~/commands/topic-post.js");

View File

@@ -293,7 +293,16 @@ export async function startMcpServer(): Promise<void> {
You are "${myName}"${myRole ? ` (${myRole})` : ""} — a peer in the claudemesh network. Your groups: ${myGroups}. You are one of several Claude Code sessions connected to the same mesh. No orchestrator exists — peers are equals. Your identity comes from your name and group roles, not from a central authority.
## Responding to messages
When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Pause your current task, reply via send_message, then resume. Read from_name, mesh_slug, and priority from the channel attributes. Reply by setting \`to\` to the sender's from_name (display name). Stay in character per your system prompt. Do not ignore low-priority messages — acknowledge them briefly even if you defer action.
When you receive a <channel source="claudemesh" ...> message, RESPOND IMMEDIATELY. Pause your current task, reply via send_message (or \`claudemesh topic post --reply-to <message_id>\` for topic threads), then resume. Stay in character per your system prompt. Do not ignore low-priority messages — acknowledge them briefly even if you defer action.
The channel attributes carry everything you need to reply — no extra lookups:
- \`from_name\` — sender display name. Use as the \`to\` arg when replying to a DM.
- \`from_pubkey\` / \`from_member_id\` — stable ids. Use \`from_member_id\` if the sender's display name might change.
- \`mesh_slug\` — pass via \`--mesh\` if your default mesh differs.
- \`priority\`\`now\` / \`next\` / \`low\`.
- \`message_id\` — id of THIS message. To thread a reply onto it in a topic, run \`claudemesh topic post <topic> "<text>" --reply-to <message_id>\`.
- \`topic\` — set when the message arrived through a topic (vs DM). Reply in the same topic.
- \`reply_to_id\` — set when the incoming message is itself a reply. Render thread context if you re-narrate.
If the channel meta contains \`subtype: reminder\`, this is a scheduled reminder you set for yourself — act on it immediately (no reply needed).
@@ -678,13 +687,18 @@ Your message mode is "${messageMode}".
content,
meta: {
from_id: fromPubkey,
from_pubkey: fromPubkey,
from_name: fromName,
...(msg.senderMemberId ? { from_member_id: msg.senderMemberId } : {}),
mesh_slug: client.meshSlug,
mesh_id: client.meshId,
priority: msg.priority,
sent_at: msg.createdAt,
delivered_at: msg.receivedAt,
kind: msg.kind,
message_id: msg.messageId,
...(msg.topic ? { topic: msg.topic } : {}),
...(msg.replyToId ? { reply_to_id: msg.replyToId } : {}),
...(msg.subtype ? { subtype: msg.subtype } : {}),
},
},

View File

@@ -101,6 +101,14 @@ export interface InboundPush {
messageId: string;
meshId: string;
senderPubkey: string;
/** Stable mesh.member id of the sender — preferred id for replies. */
senderMemberId?: string;
/** Sender's current display name (a join from the broker). */
senderName?: string;
/** Topic name when the push originated from a topic post (vs DM). */
topic?: string;
/** Server-side id of the parent message when this push is a reply. */
replyToId?: string;
priority: Priority;
nonce: string;
ciphertext: string;
@@ -2028,6 +2036,10 @@ export class BrokerClient {
messageId: String(msg.messageId ?? ""),
meshId: String(msg.meshId ?? ""),
senderPubkey,
...(msg.senderMemberId ? { senderMemberId: String(msg.senderMemberId) } : {}),
...(msg.senderName ? { senderName: String(msg.senderName) } : {}),
...(msg.topic ? { topic: String(msg.topic) } : {}),
...(msg.replyToId ? { replyToId: String(msg.replyToId) } : {}),
priority: (msg.priority as Priority) ?? "next",
nonce,
ciphertext,

View File

@@ -77,6 +77,12 @@ const sendMessageSchema = z.object({
* MUST send this array.
*/
mentions: z.array(z.string().min(1).max(64)).max(16).optional(),
/**
* Optional id of a previous topic message this one replies to. Server
* verifies the parent exists in the same topic; otherwise silently
* drops the reference (treated as a top-level post).
*/
replyToId: z.string().min(1).max(128).optional(),
});
/**
@@ -158,6 +164,21 @@ export const v1Router = new Hono<Env>()
// legacy keys with no issuer.
const senderMemberId = key.issuedByMemberId ?? ownerMember.id;
// Validate replyToId belongs to the same topic before insert.
let validatedReplyTo: string | null = null;
if (body.replyToId) {
const [parent] = await db
.select({
id: meshTopicMessage.id,
topicId: meshTopicMessage.topicId,
})
.from(meshTopicMessage)
.where(eq(meshTopicMessage.id, body.replyToId));
if (parent && parent.topicId === topic.id) {
validatedReplyTo = parent.id;
}
}
// Persist to history (topic_message) + ephemeral queue (message_queue).
// Broker's drain loop picks up the queue entry and pushes to live peers.
const [historyRow] = await db
@@ -168,6 +189,7 @@ export const v1Router = new Hono<Env>()
nonce: body.nonce,
ciphertext: body.ciphertext,
bodyVersion: body.bodyVersion,
replyToId: validatedReplyTo,
})
.returning({ id: meshTopicMessage.id });
@@ -238,6 +260,8 @@ export const v1Router = new Hono<Env>()
topic: body.topic,
topicId: topic.id,
notifications,
bodyVersion: body.bodyVersion,
...(validatedReplyTo ? { replyToId: validatedReplyTo } : {}),
});
})
@@ -400,6 +424,7 @@ export const v1Router = new Hono<Env>()
nonce: meshTopicMessage.nonce,
ciphertext: meshTopicMessage.ciphertext,
bodyVersion: meshTopicMessage.bodyVersion,
replyToId: meshTopicMessage.replyToId,
createdAt: meshTopicMessage.createdAt,
})
.from(meshTopicMessage)
@@ -423,11 +448,13 @@ export const v1Router = new Hono<Env>()
topicId: topic.id,
messages: rows.map((r) => ({
id: r.id,
senderMemberId: r.senderMemberId,
senderPubkey: r.senderPubkey,
senderName: r.senderName,
nonce: r.nonce,
ciphertext: r.ciphertext,
bodyVersion: r.bodyVersion,
replyToId: r.replyToId,
createdAt: r.createdAt.toISOString(),
})),
});
@@ -495,11 +522,13 @@ export const v1Router = new Hono<Env>()
const rows = await db
.select({
id: meshTopicMessage.id,
senderMemberId: meshTopicMessage.senderMemberId,
senderPubkey: meshMember.peerPubkey,
senderName: meshMember.displayName,
nonce: meshTopicMessage.nonce,
ciphertext: meshTopicMessage.ciphertext,
bodyVersion: meshTopicMessage.bodyVersion,
replyToId: meshTopicMessage.replyToId,
createdAt: meshTopicMessage.createdAt,
})
.from(meshTopicMessage)
@@ -522,11 +551,13 @@ export const v1Router = new Hono<Env>()
id: r.id,
data: JSON.stringify({
id: r.id,
senderMemberId: r.senderMemberId,
senderPubkey: r.senderPubkey,
senderName: r.senderName,
nonce: r.nonce,
ciphertext: r.ciphertext,
bodyVersion: r.bodyVersion,
replyToId: r.replyToId,
createdAt: r.createdAt.toISOString(),
}),
});

View File

@@ -0,0 +1,15 @@
-- Threaded replies on topic messages (v0.3.1).
--
-- Adds a self-FK column so any topic message can be marked as a reply to a
-- previous message in the same topic. ON DELETE SET NULL because deleting
-- a parent message shouldn't ripple-delete the children — the thread just
-- becomes "in reply to a deleted message".
--
-- Index supports the cheap backlink lookup: "give me all replies to X".
ALTER TABLE "mesh"."topic_message"
ADD COLUMN IF NOT EXISTS "reply_to_id" text
REFERENCES "mesh"."topic_message"("id") ON DELETE SET NULL ON UPDATE CASCADE;
CREATE INDEX IF NOT EXISTS "topic_message_by_reply_to"
ON "mesh"."topic_message" ("reply_to_id");

View File

@@ -1491,11 +1491,13 @@ export const meshTopicMessage = meshSchema.table(
* a v2 message still resolves @-mentions correctly.
*/
bodyVersion: integer().notNull().default(1),
replyToId: text("reply_to_id"),
createdAt: timestamp().defaultNow().notNull(),
},
(t) => [
index("topic_message_by_topic_time").on(t.topicId, t.createdAt),
index("topic_message_by_version").on(t.bodyVersion),
index("topic_message_by_reply_to").on(t.replyToId),
],
);