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

@@ -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),
],
);