git » sdk » commit 3a59f7f

Combine corrections before/after inserting

author Eric Roberts
2026-09-15 15:59:33 UTC
committer Eric Roberts
2026-09-21 17:49:22 UTC
parent d293727d56fac8c32227e819cfa53918e5b67756

Combine corrections before/after inserting

Previously if we got the original message and a correction in the same
batch in storeMessages we would:

IDB: Store both messages and return both messages

Sqlite: Properly store the messages, but return both messages

The solutions are slightly different, but use the same code, the new
ChatMessageCombiner which can take a list of messages and combine
originals and corrections (or just multiple corrections of the same
message).

In IDB we combine first, so that we only store the combined message.

In Sqlite everything is stored correctly but we need to combine before
returning so we don't return duplicates.

borogove/ChatMessage.hx +4 -0
borogove/ChatMessageCombiner.hx +80 -0
borogove/persistence/IDB.js +5 -2
borogove/persistence/Sqlite.hx +7 -3
test/TestAll.hx +1 -0
test/TestChatMessageCombiner.hx +117 -0
test/persistence-tests.ts +86 -0

diff --git a/borogove/ChatMessage.hx b/borogove/ChatMessage.hx
index 3e9bde4..22ad453 100644
--- a/borogove/ChatMessage.hx
+++ b/borogove/ChatMessage.hx
@@ -519,6 +519,10 @@ class ChatMessage {
 				((!isIncoming() && !other.isIncoming()) || (senderId == other.senderId && versions.length > 0)));
 	}
 
+	public static function combineCorrections(messages: Array<ChatMessage>): Array<ChatMessage> {
+		return ChatMessageCombiner.combine(messages);
+	}
+
 	/**
 		Is this an incoming message?
 	**/
diff --git a/borogove/ChatMessageCombiner.hx b/borogove/ChatMessageCombiner.hx
new file mode 100644
index 0000000..162376e
--- /dev/null
+++ b/borogove/ChatMessageCombiner.hx
@@ -0,0 +1,80 @@
+package borogove;
+
+using Lambda;
+
+@:expose
+class ChatMessageCombiner {
+	public static function combine(messages: Array<ChatMessage>): Array<ChatMessage> {
+		return messages
+			.fold(
+				(message, grouped:Map<String, Array<ChatMessage>>) -> {
+					final key = correctionKey(message);
+					final messages = grouped[key] ?? [];
+					grouped.set(key, messages.concat([message]));
+					grouped;
+				},
+				new Map<String, Array<ChatMessage>>()
+			)
+			.map(messages -> combineMessages(messages));
+	}
+
+	private static function correctionKey(message: ChatMessage): String {
+		if (message.localId == null) return ID.unique();
+
+		final sender = message.type == MessageCall ? "call" : message.senderId;
+		return message.localId + "\n" + message.chatId() + "\n" + sender;
+	}
+
+	private static function combineMessages(messages: Array<ChatMessage>): ChatMessage {
+		final original = messages.find(message -> message.versions.length == 0) ?? messages[0];
+		final combinedVersions = combineVersions(original, messages);
+		final newest = combinedVersions[0];
+
+		final builder = ChatMessageBuilder.fromMessage(newest);
+		builder.localId = original.localId;
+		builder.serverId = original.serverId;
+		builder.serverIdBy = original.serverIdBy;
+		builder.sortId = original.sortId;
+		builder.timestamp = original.timestamp;
+		builder.replyId = original.replyId;
+		builder.reactions = original.reactions;
+		if (original.type == MessageCall) {
+			builder.direction = original.direction;
+			builder.senderId = original.senderId;
+			builder.from = original.from;
+			builder.to = original.to;
+			builder.replyTo = original.replyTo.array();
+			builder.recipients = original.recipients.array();
+		}
+		builder.versions = combinedVersions;
+		return builder.build();
+	}
+
+	private static function combineVersions(
+		original: ChatMessage,
+		messages: Array<ChatMessage>,
+	): Array<ChatMessage> {
+		final versions = messages.flatMap(message -> message.versions.array());
+		if (original.versions.length == 0) versions.push(original);
+
+		final sortedVersions = versions.fold(
+			(version, uniqueVersions:Map<String, ChatMessage>) -> {
+				final id = identity(version);
+				final previous = uniqueVersions[id];
+				if (previous == null || version.timestamp > previous.timestamp) {
+					uniqueVersions.set(id, version);
+				}
+				uniqueVersions;
+			},
+			new Map<String, ChatMessage>()
+		).map(version -> version);
+		sortedVersions.sort((a, b) -> Reflect.compare(b.timestamp, a.timestamp));
+		return sortedVersions;
+	}
+
+	private static function identity(message: ChatMessage): String {
+		return message.serverId == null
+			? "local:" + message.localId
+			: "server:" + message.serverId + "\n" + message.serverIdBy;
+	}
+}
diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js
index 3f45d7e..5a53b83 100644
--- a/borogove/persistence/IDB.js
+++ b/borogove/persistence/IDB.js
@@ -8,6 +8,7 @@ import {
 	borogove_Channel,
 	borogove_ChatAttachment,
 	borogove_ChatMessageBuilder,
+	borogove_ChatMessageCombiner,
 	borogove_CustomEmojiReaction,
 	borogove_DirectChat,
 	borogove_Hash,
@@ -1221,12 +1222,14 @@ export default async (dbname, media, tokenize, stemmer) => {
 				await Promise.all(m.attachments.map((a) => a.lookup(this)));
 			}
 
+			const combinedMessages =
+				await borogove_ChatMessageCombiner.combine(messages);
 			const tx = db.transaction(["messages", "reactions"], "readwrite");
 			const store = tx.objectStore("messages");
 			const promises = [];
 			tx.onerror = console.error;
-			for (const [index, m] of messages.entries()) {
-				const isLast = index + 1 >= messages.length;
+			for (const [index, m] of combinedMessages.entries()) {
+				const isLast = index + 1 >= combinedMessages.length;
 				promises.push(this.storeMessage(tx, store, account, m, isLast));
 			}
 
diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx
index 8120bc3..ab3b3f3 100644
--- a/borogove/persistence/Sqlite.hx
+++ b/borogove/persistence/Sqlite.hx
@@ -834,9 +834,13 @@ class Sqlite implements Persistence implements KeyValueStore {
 
 		return storeMessagesSerialized.run(() ->
 			// Hmm, if there is an existing one this loses the original timestamp though
-			insertMessages(accountId, messages).then(_ ->
-				thenshim.PromiseTools.all(messages.map(m -> fetchFromStub(accountId, m)))
-			).then(ms ->
+			insertMessages(accountId, messages).then(_ -> {
+				// Sqlite will store originals and their corrections properly, but
+				// here we just map the messages we received, which means we'll return
+				// both unless we combine them.
+				final combinedMessages = ChatMessageCombiner.combine(messages);
+				thenshim.PromiseTools.all(combinedMessages.map(m -> fetchFromStub(accountId, m)));
+			}).then(ms ->
 				thenshim.PromiseTools.all(ms.flatMap(m -> m.attachments.map(a -> a.lookup(this)))).then(_ ->
 					hydrateReplyTo(accountId, ms, replyTos)
 				)
diff --git a/test/TestAll.hx b/test/TestAll.hx
index 73384e9..707b619 100644
--- a/test/TestAll.hx
+++ b/test/TestAll.hx
@@ -17,6 +17,7 @@ class TestAll {
 			new TestCapsRepo(),
 			new TestChat(),
 			new TestChatMessage(),
+			new TestChatMessageCombiner(),
 			new TestChatMessageBuilder(),
 			new TestClient(),
 			new TestEmojiUtil(),
diff --git a/test/TestChatMessageCombiner.hx b/test/TestChatMessageCombiner.hx
new file mode 100644
index 0000000..149b170
--- /dev/null
+++ b/test/TestChatMessageCombiner.hx
@@ -0,0 +1,117 @@
+package test;
+
+import utest.Assert;
+import borogove.ChatMessage;
+import borogove.ChatMessageCombiner;
+import borogove.JID;
+import borogove.Message;
+
+@:access(borogove)
+class TestChatMessageCombiner extends utest.Test {
+	public function testCombineCorrections() {
+		final original = message({ localId: "original", timestamp: "2024-01-01T00:00:00Z", sortId: "m", body: "old" });
+		final correction = correction(original, "version", "2024-01-02T00:00:00Z", "new");
+		final combined = ChatMessageCombiner.combine([original, correction]);
+
+		Assert.equals(1, combined.length);
+		Assert.equals("original", combined[0].localId);
+		Assert.equals("m", combined[0].sortId);
+		Assert.equals("new", combined[0].body().toPlainText());
+		Assert.equals(2, combined[0].versions.length);
+		Assert.equals("version", combined[0].versions[0].localId);
+		Assert.equals("original", combined[0].versions[1].localId);
+	}
+
+	public function testCombineCorrectionsKeepsUnrelatedMessages() {
+		final first = message({ localId: "first", timestamp: "2024-01-01T00:00:00Z" });
+		final original = message({ localId: "original", timestamp: "2024-01-02T00:00:00Z" });
+		final other = message({ localId: "other", chatId: "carol@example.com" });
+		final correction = correction(original, "version", "2024-01-03T00:00:00Z", "new");
+		final combined = ChatMessageCombiner.combine([first, correction, other, original]);
+
+		final localIds = combined.map(m -> m.localId);
+		localIds.sort(Reflect.compare);
+		Assert.equals(3, combined.length);
+		Assert.same(["first", "original", "other"], localIds);
+	}
+
+	public function testCombineCorrectionsSupportsVersionedHeadsAndDeduplicates() {
+		final original = message({ localId: "original", timestamp: "2024-01-01T00:00:00Z" });
+		final first = correction(original, "v1", "2024-01-02T00:00:00Z", "first");
+		final second = correction(original, "v2", "2024-01-03T00:00:00Z", "second");
+		final combined = ChatMessageCombiner.combine([first, original, second, second]);
+
+		Assert.equals(1, combined.length);
+		Assert.equals("second", combined[0].body().toPlainText());
+		Assert.equals(3, combined[0].versions.length);
+		Assert.equals("v2", combined[0].versions[0].localId);
+		Assert.equals("v1", combined[0].versions[1].localId);
+		Assert.equals("original", combined[0].versions[2].localId);
+	}
+
+	public function testCombineCorrectionsWithoutOriginal() {
+		final original = message({ localId: "original", timestamp: "2024-01-01T00:00:00Z", sortId: "m" });
+		final first = correction(original, "v1", "2024-01-02T00:00:00Z", "first");
+		final second = correction(original, "v2", "2024-01-03T00:00:00Z", "second");
+		final third = correction(original, "v3", "2024-01-04T00:00:00Z", "third");
+		final combined = ChatMessageCombiner.combine([third, first, second]);
+
+		Assert.equals(1, combined.length);
+		Assert.equals("original", combined[0].localId);
+		Assert.equals("third", combined[0].body().toPlainText());
+		Assert.equals(3, combined[0].versions.length);
+		Assert.equals("v3", combined[0].versions[0].localId);
+		Assert.equals("v2", combined[0].versions[1].localId);
+		Assert.equals("v1", combined[0].versions[2].localId);
+	}
+
+	public function testCombineCorrectionsCallSenderMismatch() {
+		final original = message({ localId: "call", senderId: "alice@example.com", type: MessageCall });
+		final callCorrection = correction(original, "call-version", "2024-01-02T00:00:00Z", "updated", "bob@example.com");
+		final textOriginal = message({ localId: "text", senderId: "alice@example.com" });
+		final textCorrection = correction(textOriginal, "text-version", "2024-01-02T00:00:00Z", "updated", "bob@example.com");
+
+		Assert.equals(3, ChatMessageCombiner.combine([original, callCorrection, textOriginal, textCorrection]).length);
+	}
+
+	public function testCombineCorrectionsDoesNotMutateInputs() {
+		final original = message({ localId: "original" });
+		final correctionM = correction(original, "version", "2024-01-02T00:00:00Z", "new");
+		final input = [original, correctionM];
+		final originalVersions = original.versions.length;
+		ChatMessageCombiner.combine(input);
+
+		Assert.equals(2, input.length);
+		Assert.equals(originalVersions, original.versions.length);
+		Assert.equals(1, correctionM.versions.length);
+	}
+
+	private function message(params: {
+		?localId: Null<String>, ?chatId: String, ?senderId: String, ?type: MessageType,
+		?versions: Array<ChatMessage>, ?timestamp: String, ?body: String, ?sortId: String
+	}): ChatMessage {
+		final builder = new borogove.ChatMessageBuilder();
+		builder.localId = params.localId;
+		builder.from = JID.parse(params.senderId ?? "alice@example.com");
+		builder.to = JID.parse(params.chatId ?? "bob@example.com");
+		builder.recipients = [builder.to];
+		builder.senderId = params.senderId ?? "alice@example.com";
+		builder.type = params.type ?? MessageChat;
+		builder.versions = params.versions ?? [];
+		builder.timestamp = params.timestamp;
+		builder.sortId = params.sortId;
+		builder.setBody(borogove.Html.text(params.body ?? "message"));
+		return builder.build();
+	}
+
+	private function correction(original: ChatMessage, versionId: String, timestamp: String, body: String, ?senderId: String): ChatMessage {
+		final originalId = original.localId ?? throw "Correction requires an original local ID";
+		final chatId = original.chatId();
+		final correctionSenderId = senderId ?? original.senderId;
+		return message({
+			localId: originalId, chatId: chatId, senderId: correctionSenderId, type: original.type,
+			timestamp: timestamp, body: body,
+			versions: [message({ localId: versionId, chatId: chatId, senderId: correctionSenderId, type: original.type, timestamp: timestamp, body: body })]
+		});
+	}
+}
diff --git a/test/persistence-tests.ts b/test/persistence-tests.ts
index beee6c8..6131a0c 100644
--- a/test/persistence-tests.ts
+++ b/test/persistence-tests.ts
@@ -1977,6 +1977,92 @@ export function sharedPersistenceTests(test) {
 			original.text,
 		]);
 	});
+
+	test("storeMessages properly stores two corrections in the same batch when original is already stored", async ({
+		factories,
+		borogove,
+		persistence,
+	}) => {
+		const account = faker.internet.email();
+		const to = borogove.JID.parse(account);
+		const original = factories.message({ to, text: "Original message" });
+		await persistence.storeMessages(account, [original]);
+
+		const corrections = factories
+			.corrections(original, [
+				{ text: "First correction" },
+				{ text: "Second correction" },
+			])
+			.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
+
+		const [stored, ...restStored] = await persistence.storeMessages(
+			account,
+			corrections,
+		);
+
+		expect(restStored.length).toBe(0);
+		expect(stored.localId).toBe(original.localId);
+		expect(stored.serverId).toBe(original.serverId);
+		expect(stored.text).toBe(corrections[0].text);
+		expect(stored.versions.map((version) => version.text)).toEqual([
+			...corrections.map((c) => c.text),
+			original.text,
+		]);
+
+		const [fetched, ...restFetched] = await persistence.getMessagesBefore(
+			account,
+			original.chatId(),
+		);
+
+		expect(restFetched.length).toBe(0);
+		expect(fetched?.localId).toBe(original.localId);
+		expect(fetched?.serverId).toBe(original.serverId);
+		expect(fetched?.text).toBe(corrections[0].text);
+		expect(fetched?.versions.map((version) => version.text)).toEqual([
+			...corrections.map((c) => c.text),
+			original.text,
+		]);
+	});
+
+	test("storeMessages properly stores corrected messages when original in same batch", async ({
+		factories,
+		borogove,
+		persistence,
+	}) => {
+		const account = faker.internet.email();
+		const to = borogove.JID.parse(account);
+		const original = factories.message({ to, text: "Original message" });
+
+		const [correction] = factories.corrections(original, [
+			{ text: "Corrected message" },
+		]);
+		const [storedCorrected, ...restStored] = await persistence.storeMessages(
+			account,
+			[original, correction],
+		);
+
+		expect(restStored.length).toBe(0);
+		expect(storedCorrected.localId).toBe(original.localId);
+		expect(storedCorrected.serverId).toBe(original.serverId);
+		expect(storedCorrected.text).toBe(correction.text);
+
+		expect(storedCorrected.versions.map((version) => version.text)).toEqual([
+			correction.text,
+			original.text,
+		]);
+
+		const [fetched, ...restFetched] = await persistence.getMessagesBefore(
+			account,
+			original.chatId(),
+		);
+
+		expect(restFetched.length).toBe(0);
+		expect(fetched?.text).toBe(correction.text);
+		expect(fetched?.versions.map((version) => version.text)).toEqual([
+			correction.text,
+			original.text,
+		]);
+	});
 }
 
 type TestKeyPair = {