git » sdk » commit c9e959e

Store debug info on messages

author Eric Roberts
2026-09-16 13:25:08 UTC
committer Stephen Paul Weber
2026-09-16 14:06:46 UTC
parent 241512a73d1f20d0eb32f7504023ba3c909df415

Store debug info on messages

It's unclear why certain sort problems are happening, but we hope that
storing some debug information may show us where to look. If all the
messages that have issues come in via mam for example, that gives us
something to start with.

borogove/Chat.hx +27 -3
borogove/ChatMessage.hx +5 -0
borogove/ChatMessageBuilder.hx +8 -0
borogove/Client.hx +9 -1
borogove/Importer.hx +6 -1
borogove/MessageSync.hx +17 -1
borogove/Push.hx +6 -1
borogove/calls/Session.hx +5 -0
borogove/persistence/IDB.js +1 -0
borogove/persistence/Sqlite.hx +16 -2
test/TestImporter.hx +2 -0
test/TestSortId.hx +11 -0
test/TestSqlite.hx +16 -3
test/persistence-tests.ts +34 -0

diff --git a/borogove/Chat.hx b/borogove/Chat.hx
index 2d65915..bb8e97b 100644
--- a/borogove/Chat.hx
+++ b/borogove/Chat.hx
@@ -1226,7 +1226,14 @@ class DirectChat extends Chat {
 	@:allow(borogove)
 	private function prepareIncomingMessage(message:ChatMessageBuilder, stanza:Stanza) {
 		message.syncPoint = !syncing();
-		if (message.sortId == null) message.sortId = client.nextSortId();
+		if (message.sortId == null) {
+			final lower = client.sortId;
+			message.sortId = client.nextSortId();
+			message.debug = {
+				source: "live",
+				sortId: { method: "between", lower: lower, upper: null },
+			};
+		}
 		return message;
 	}
 
@@ -2259,12 +2266,22 @@ class Channel extends Chat {
 		if (message.type == MessageChat) message.type = MessageChannelPrivate;
 		if (message.sortId == null) {
 			if (sortId != null && message.type == MessageChannel) {
-				sortId = message.sortId = FractionalIndexing.between(sortId, null, FractionalIndexing.BASE_95_DIGITS);
+				final lower = sortId;
+				sortId = message.sortId = FractionalIndexing.between(lower, null, FractionalIndexing.BASE_95_DIGITS);
+				message.debug = {
+					source: "live",
+					sortId: { method: "between", lower: lower, upper: null },
+				};
 			} else {
 				if (message.type == MessageChannel) {
 					throw "Cannot store message before we know sortId";
 				}
+				final lower = client.sortId;
 				message.sortId = client.nextSortId();
+				message.debug = {
+					source: "live",
+					sortId: { method: "between", lower: lower, upper: null },
+				};
 			}
 		}
 		final occupantId = stanza.getChild("occupant-id", "urn:xmpp:occupant-id:0")?.attr?.get("id");
@@ -2286,7 +2303,14 @@ class Channel extends Chat {
 		message.to = JID.parse(chatId);
 		message.recipients = [message.to];
 		if (message.localId == null) message.localId = ID.unique();
-		if (sortId != null && message.sortId == null) sortId = message.sortId = FractionalIndexing.between(sortId, null, FractionalIndexing.BASE_95_DIGITS);
+		if (sortId != null && message.sortId == null) {
+			final lower = sortId;
+			sortId = message.sortId = FractionalIndexing.between(lower, null, FractionalIndexing.BASE_95_DIGITS);
+			message.debug = {
+				source: "outgoing",
+				sortId: { method: "between", lower: lower, upper: null },
+			};
+		}
 		return message;
 	}
 
diff --git a/borogove/ChatMessage.hx b/borogove/ChatMessage.hx
index 904aa71..04bf34d 100644
--- a/borogove/ChatMessage.hx
+++ b/borogove/ChatMessage.hx
@@ -226,6 +226,9 @@ class ChatMessage {
 	**/
 	public final attachments: ReadOnlyArray<ChatAttachment>;
 
+	@:allow(borogove)
+	private final debug: Null<Dynamic>;
+
 	/**
 		Map of reactions to this message
 	**/
@@ -317,6 +320,7 @@ class ChatMessage {
 		?replyToMessage: Null<ChatMessage>,
 		?threadId: Null<String>,
 		?attachments: Array<ChatAttachment>,
+		?debug: Null<Dynamic>,
 		?reactions: Map<String, Array<Reaction>>,
 		?text: Null<String>,
 		?lang: Null<String>,
@@ -345,6 +349,7 @@ class ChatMessage {
 		this.replyToMessage = params.replyToMessage;
 		this.threadId = params.threadId;
 		this.attachments = params.attachments ?? [];
+		this.debug = params.debug;
 		this.reactions = params.reactions ?? ([] : Map<String, Array<Reaction>>);
 		this.text = params.text;
 		this.lang = params.lang;
diff --git a/borogove/ChatMessageBuilder.hx b/borogove/ChatMessageBuilder.hx
index be9d41d..b5a5742 100644
--- a/borogove/ChatMessageBuilder.hx
+++ b/borogove/ChatMessageBuilder.hx
@@ -94,6 +94,9 @@ class ChatMessageBuilder {
 	**/
 	public var attachments (default, null): Array<ChatAttachment> = [];
 
+	@:allow(borogove)
+	private var debug: Null<Dynamic> = null;
+
 	/**
 		Map of reactions to this message
 	**/
@@ -177,6 +180,7 @@ class ChatMessageBuilder {
 		?replyToMessage: Null<ChatMessage>,
 		?threadId: Null<String>,
 		?attachments: Array<ChatAttachment>,
+		?debug: Null<Dynamic>,
 		?reactions: Map<String, Array<Reaction>>,
 		?text: Null<String>,
 		?subject: Null<String>,
@@ -199,6 +203,7 @@ class ChatMessageBuilder {
 		this.replyToMessage = params?.replyToMessage;
 		this.threadId = params?.threadId;
 		this.attachments = params?.attachments ?? [];
+		this.debug = params?.debug;
 		this.reactions = params?.reactions ?? ([] : Map<String, Array<Reaction>>);
 		this.lang = params?.lang;
 		this.direction = params?.direction ?? MessageSent;
@@ -235,6 +240,7 @@ class ChatMessageBuilder {
 		builder.replyToMessage = m.replyToMessage;
 		builder.threadId = m.threadId;
 		builder.attachments = m.attachments.array();
+		builder.debug = m.debug;
 		builder.reactions = m.reactions;
 		builder.direction = m.direction;
 		builder.status = m.status;
@@ -267,6 +273,7 @@ class ChatMessageBuilder {
 		builder.replyTo = m.replyTo.array();
 		builder.replyToMessage = m.replyToMessage;
 		builder.threadId = m.threadId;
+		builder.debug = m.debug;
 		builder.reactions = m.reactions;
 		builder.direction = m.direction;
 		builder.status = m.status;
@@ -406,6 +413,7 @@ class ChatMessageBuilder {
 			replyToMessage: replyToMessage,
 			threadId: threadId,
 			attachments: attachments,
+			debug: debug,
 			reactions: reactions,
 			text: text,
 			lang: lang,
diff --git a/borogove/Client.hx b/borogove/Client.hx
index 2889827..1005db5 100644
--- a/borogove/Client.hx
+++ b/borogove/Client.hx
@@ -117,6 +117,7 @@ class Client extends EventEmitter {
 	private var fastMechanism: Null<String> = null;
 	private var token: Null<String> = null;
 	private var fastCount: Null<Int> = null;
+	@:allow(borogove)
 	private var sortId: String = "a ";
 	private var rosterVer: Null<String> = null;
 	private final pendingCaps: Map<String, Array<(Null<Caps>)->Chat>> = [];
@@ -1993,7 +1994,14 @@ class Client extends EventEmitter {
 
 	@:allow(borogove)
 	private function storeMessageBuilder(builder: ChatMessageBuilder): Promise<ChatMessage> {
-		if (builder.sortId == null) builder.sortId = nextSortId();
+		if (builder.sortId == null) {
+			final lower = sortId;
+			builder.sortId = nextSortId();
+			builder.debug = {
+				source: "outgoing",
+				sortId: { method: "between", lower: lower, upper: null },
+			};
+		}
 		return storeMessages([builder.build()]).then(result -> result[0]);
 	}
 
diff --git a/borogove/Importer.hx b/borogove/Importer.hx
index fc65de8..1919e9c 100644
--- a/borogove/Importer.hx
+++ b/borogove/Importer.hx
@@ -143,7 +143,8 @@ class Importer {
 			}
 		}
 
-		sortA = FractionalIndexing.between(sortA, sortB, FractionalIndexing.BASE_95_DIGITS);
+		final sortLower = sortA;
+		sortA = FractionalIndexing.between(sortLower, sortB, FractionalIndexing.BASE_95_DIGITS);
 		var timestamp = resultStanza.findText("{urn:xmpp:forward:0}forwarded/{urn:xmpp:delay}delay@stamp");
 		if (timestamp != null) {
 			// If no subseconds, fix them to at least sort right
@@ -170,6 +171,10 @@ class Importer {
 
 		final msg = Message.fromStanza(originalMessage, targetAccount == null ? new JID(item, host) : JID.parse(targetAccount), (builder, stanza) -> {
 			builder.sortId = sortA;
+			builder.debug = {
+				source: "import",
+				sortId: { method: "between", lower: sortLower, upper: sortB },
+			};
 			builder.serverId = resultStanza.attr.get("id");
 			builder.serverIdBy = source;
 			if (timestamp != null && builder.timestamp == null) builder.timestamp = timestamp;
diff --git a/borogove/MessageSync.hx b/borogove/MessageSync.hx
index 47c05de..46c013a 100644
--- a/borogove/MessageSync.hx
+++ b/borogove/MessageSync.hx
@@ -81,7 +81,8 @@ class MessageSync {
 			if (originalMessage == null) { // No message, nothing for us to do
 				return EventHandled;
 			}
-			sortA = FractionalIndexing.between(sortA, sortB, FractionalIndexing.BASE_95_DIGITS);
+			final sortLower = sortA;
+			sortA = FractionalIndexing.between(sortLower, sortB, FractionalIndexing.BASE_95_DIGITS);
 			var timestamp = result.findText("{urn:xmpp:forward:0}forwarded/{urn:xmpp:delay}delay@stamp");
 			if (timestamp == null) {
 				trace("MAM result with no timestamp", result);
@@ -118,6 +119,11 @@ class MessageSync {
 
 					return Message.fromStanza(decryptedStanza, client.jid, (builder, stanza) -> {
 						builder.sortId = sortId;
+						builder.debug = {
+							source: "mam",
+							serviceJID: serviceJID,
+							sortId: { method: "between", lower: sortLower, upper: sortB },
+						};
 						builder.serverId = result.attr.get("id");
 						builder.serverIdBy = serviceJID;
 						builder.encryption = decryptionResult.encryptionInfo;
@@ -128,6 +134,11 @@ class MessageSync {
 					trace("MAM: Decryption failed: "+err);
 					return Message.fromStanza(originalMessage, client.jid, (builder, stanza) -> {
 							builder.sortId = sortA;
+							builder.debug = {
+								source: "mam",
+								serviceJID: serviceJID,
+								sortId: { method: "between", lower: sortLower, upper: sortB },
+							};
 							builder.serverId = result.attr.get("id");
 							builder.serverIdBy = serviceJID;
 							if (timestamp != null && builder.timestamp == null) builder.timestamp = timestamp;
@@ -144,6 +155,11 @@ class MessageSync {
 
 			final msg = Message.fromStanza(originalMessage, client.jid, (builder, stanza) -> {
 				builder.sortId = sortId;
+				builder.debug = {
+					source: "mam",
+					serviceJID: serviceJID,
+					sortId: { method: "between", lower: sortLower, upper: sortB },
+				};
 				builder.serverId = result.attr.get("id");
 				builder.serverIdBy = serviceJID;
 				if (timestamp != null && builder.timestamp == null) builder.timestamp = timestamp;
diff --git a/borogove/Push.hx b/borogove/Push.hx
index 18dd87a..16cf4ef 100644
--- a/borogove/Push.hx
+++ b/borogove/Push.hx
@@ -42,12 +42,17 @@ class Push {
 					persistence.syncPoint(message.account(), message.chatId()).then(point -> point?.sortId ?? "a ") :
 					persistence.getStreamManagement(message.account()).then(data -> data.sortId)
 				).then(sortId -> {
-					final sortId = FractionalIndexing.between(sortId, null, FractionalIndexing.BASE_95_DIGITS);
+					final lower = sortId;
+					final sortId = FractionalIndexing.between(lower, null, FractionalIndexing.BASE_95_DIGITS);
 					final toStore = ChatMessage.fromStanza(
 						stanza,
 						JID.parse(stanza.attr.get("to")).asBare(),
 						(builder, stanza) -> {
 							builder.sortId = sortId;
+							builder.debug = {
+								source: "push",
+								sortId: { method: "between", lower: lower, upper: null },
+							};
 							return builder;
 						}
 					);
diff --git a/borogove/calls/Session.hx b/borogove/calls/Session.hx
index 438e5a9..6f3f38e 100644
--- a/borogove/calls/Session.hx
+++ b/borogove/calls/Session.hx
@@ -68,7 +68,12 @@ private function mkCallMessage(to: JID, client: Client, event: Stanza) {
 		m.versions = [m.build()];
 	}
 	m.localId = event.attr.get("id");
+	final lower = client.sortId;
 	m.sortId = client.nextSortId();
+	m.debug = {
+		source: "call",
+		sortId: { method: "between", lower: lower, upper: null },
+	};
 	return m.build();
 }
 
diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js
index 8386dc1..3f45d7e 100644
--- a/borogove/persistence/IDB.js
+++ b/borogove/persistence/IDB.js
@@ -478,6 +478,7 @@ export default async (dbname, media, tokenize, stemmer) => {
 					(a.hashes ?? []).map((h) => new borogove_Hash(h.algorithm, h.hash)),
 				),
 		);
+		message.debug = value.debug ?? null;
 		message.linkMetadata = value.linkMetadata ?? [];
 		message.reactions = hydrateReactions(value.reactions, message.timestamp);
 		message.text = value.text;
diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx
index f592c4d..8120bc3 100644
--- a/borogove/persistence/Sqlite.hx
+++ b/borogove/persistence/Sqlite.hx
@@ -362,6 +362,12 @@ class Sqlite implements Persistence implements KeyValueStore {
 							"PRAGMA user_version = 21"]);
 					}
 					return Promise.resolve(null);
+				}).then(_ -> {
+					if (version < 22) {
+						return exec(["ALTER TABLE messages ADD COLUMN debug BLOB",
+							"PRAGMA user_version = 22"]);
+					}
+					return Promise.resolve(null);
 				});
 			});
 		});
@@ -1474,6 +1480,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 			sync_point: Int,
 			sender_id: String,
 			encryption: Null<String>,
+			debug: Null<String>,
 			?stanza_id: String,
 			?versions: String
 		}>
@@ -1490,6 +1497,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 			builder.serverId = row.mam_id == "" ? null : row.mam_id;
 			builder.serverIdBy = row.mam_by == "" ? null : row.mam_by;
 			builder.sortId = row.sort_id;
+			builder.debug = row.debug == null ? null : Json.parse(row.debug);
 			if (builder.direction != row.direction) {
 				builder.direction = row.direction;
 				final replyTo = builder.replyTo;
@@ -1502,6 +1510,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 					timestamp: String,
 					stanza: String,
 					encryption: Dynamic,
+					debug: Dynamic,
 				}> = Json.parse(row.versions);
 
 				if (versions.keys().length > 1) {
@@ -1509,6 +1518,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 						final versionM = ChatMessage.fromStanza(Stanza.parse(version.stanza), accountJid, (toPushB, _) -> {
 							if (toPushB.serverId == null && versionId != toPushB.localId) toPushB.serverId = versionId;
 							toPushB.timestamp = version.timestamp;
+							toPushB.debug = version.debug;
 							return toPushB;
 						}, hydrateEncryption(version.encryption));
 						final toPush = versionM == null || versionM.versions.length < 1 ? versionM : versionM.versions[0];
@@ -1773,7 +1783,8 @@ class Sqlite implements Persistence implements KeyValueStore {
 			col("mam_by"),
 			col("sort_id"),
 			col("sync_point"),
-			col("encryption", "json(encryption) AS encryption")
+			col("encryption", "json(encryption) AS encryption"),
+			col("debug", "json(debug) AS debug")
 		]
 		.concat(customColumns ?? [])
 		.fold((column, map:Map<String, Column>) -> {
@@ -1794,7 +1805,8 @@ class Sqlite implements Persistence implements KeyValueStore {
 			json_object(
 				'timestamp', strftime('%FT%H:%M:%fZ', versions.created_at / 1000.0, 'unixepoch'),
 				'stanza', versions.stanza,
-				'encryption', json(versions.encryption)
+				'encryption', json(versions.encryption),
+				'debug', json(versions.debug)
 			)
 		) AS versions,
 		messages.direction,
@@ -1809,6 +1821,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 		messages.sync_point,
 		MAX(versions.created_at),
 		json(versions.encryption) AS encryption,
+		json(versions.debug) AS debug,
 		versions.stanza";
 
 	private static function insertCol(
@@ -1840,6 +1853,7 @@ class Sqlite implements Persistence implements KeyValueStore {
 			insertCol("stanza", (m) -> originalMessage(m).asStanza().toString()),
 			insertCol("status_text", (m) -> originalMessage(m).statusText),
 			insertCol("sort_id", (m) -> originalMessage(m).sortId),
+			insertCol("debug", (m) -> originalMessage(m).debug == null ? null : Json.stringify(originalMessage(m).debug), "jsonb(?)"),
 			insertCol("encryption", (m) -> {
 				final message = originalMessage(m);
 				return message.encryption == null ? null : JsonPrinter.print(message.encryption);
diff --git a/test/TestImporter.hx b/test/TestImporter.hx
index 40583bd..dfcba90 100644
--- a/test/TestImporter.hx
+++ b/test/TestImporter.hx
@@ -6,6 +6,7 @@ import borogove.Importer;
 import borogove.Message;
 import borogove.JID;
 
+@:access(borogove)
 class TestImporter extends utest.Test {
 	public function testOnAccount() {
 		final accounts = [];
@@ -82,6 +83,7 @@ class TestImporter extends utest.Test {
 				Assert.equals("mam-id-1", chatMsg.serverId);
 				Assert.equals("juliet@capulet.com", chatMsg.serverIdBy);
 				Assert.equals("2023-10-27T10:00:00.001Z", chatMsg.timestamp);
+				Assert.equals("import", chatMsg.debug.source);
 				Assert.isFalse(chatMsg.isIncoming());
 			default:
 				Assert.fail("Expected ChatMessageStanza");
diff --git a/test/TestSortId.hx b/test/TestSortId.hx
index 91681a6..f7df325 100644
--- a/test/TestSortId.hx
+++ b/test/TestSortId.hx
@@ -72,6 +72,8 @@ class TestSortId extends utest.Test {
 		var messagesSoFar = 0;
 		var sortIdSoFar = "a ";
 		client.addChatMessageListener((message, event) -> {
+			Assert.equals("live", message.debug.source);
+			Assert.equals("between", message.debug.sortId.method);
 			Assert.isTrue(sortIdSoFar < message.sortId, "sortIdSoFar < message.sortId");
 			sortIdSoFar = message.sortId;
 			messagesSoFar++;
@@ -98,6 +100,7 @@ class TestSortId extends utest.Test {
 		var messagesSoFar = 0;
 		var sortIdSoFar = "a ";
 		client.addChatMessageListener((message, event) -> {
+			Assert.equals("live", message.debug.source);
 			Assert.isTrue(sortIdSoFar < message.sortId, "sortIdSoFar < message.sortId");
 			sortIdSoFar = message.sortId;
 			messagesSoFar++;
@@ -140,6 +143,12 @@ class TestSortId extends utest.Test {
 			Assert.isTrue(m1.sortId < m2.sortId, "m1.sortId < m2.sortId");
 			Assert.isTrue(m2.sortId < "b00", "m2.sortId < \"b00\"");
 			Assert.isTrue(m1.timestamp < m2.timestamp, "m1.timestamp < m2.timestamp"); // fake fractional part
+			Assert.equals("mam", m1.debug.source);
+			Assert.equals("test@example.com", m1.debug.serviceJID);
+			Assert.equals("mam", m2.debug.source);
+			Assert.equals("between", m1.debug.sortId.method);
+			Assert.equals("a ", m1.debug.sortId.lower);
+			Assert.equals("b00", m1.debug.sortId.upper);
 			async.done();
 		});
 
@@ -284,6 +293,8 @@ class TestSortId extends utest.Test {
 		});
 
 		client.addSyncMessageListener((message) -> {
+			Assert.equals("mam", message.debug.source);
+			Assert.equals("syncchannel@example.com", message.debug.serviceJID);
 			Assert.isTrue(syncSortIdSoFar < message.sortId, "syncSortIdSoFar < message.sortId");
 			syncSortIdSoFar = message.sortId;
 			Assert.isTrue(syncSortIdSoFar < sortIdSoFar, "syncSortIdSoFar < sortIdSoFar");
diff --git a/test/TestSqlite.hx b/test/TestSqlite.hx
index fe59a6b..6f7bd5f 100644
--- a/test/TestSqlite.hx
+++ b/test/TestSqlite.hx
@@ -728,6 +728,7 @@ class TestSqlite extends utest.Test {
 		?syncPoint: Bool,
 		?sortId: String,
 		?encryption: EncryptionInfo,
+		?source: String,
 		?body: String,
 		?received: Bool
 	}):ChatMessage {
@@ -744,6 +745,7 @@ class TestSqlite extends utest.Test {
 		builder.syncPoint = params.syncPoint ?? false;
 		builder.versions = params.versions ?? [];
 		builder.encryption = params.encryption;
+		if (params.source != null) builder.debug = { source: params.source };
 		if (params.body != null) builder.setBody(Html.text(params.body));
 		builder.to = JID.parse(chatId);
 		builder.from = JID.parse(senderId);
@@ -815,6 +817,7 @@ class TestSqlite extends utest.Test {
 			chatId: chatId,
 			sortId: "encrypted-c0",
 			body: originalExpected.text,
+			source: "outgoing",
 			encryption: originalExpected.encryption
 		});
 		final correctionExpected = {
@@ -834,6 +837,7 @@ class TestSqlite extends utest.Test {
 			chatId: chatId,
 			sortId: "encrypted-c0",
 			body: correctionExpected.text,
+			source: "mam",
 			encryption: correctionExpected.encryption
 		});
 		final correctable = makeMessage({
@@ -850,16 +854,19 @@ class TestSqlite extends utest.Test {
 		}).then(stored -> {
 			final corrected = stored[0];
 			Assert.equals(correctionExpected.text, corrected.text);
+			Assert.equals("mam", corrected.debug.source);
 			assertEncryption(corrected, correctionExpected.encryption);
 
 			final storedCorrection = corrected.versions.find(version -> version.localId == correctionVersion.localId);
 			Assert.notNull(storedCorrection);
 			Assert.equals(correctionExpected.text, storedCorrection.text);
+			Assert.equals("mam", storedCorrection.debug.source);
 			assertEncryption(storedCorrection, correctionExpected.encryption);
 
 			final storedOriginal = corrected.versions.find(version -> version.localId == original.localId);
 			Assert.notNull(storedOriginal);
 			Assert.equals(originalExpected.text, storedOriginal.text);
+			Assert.equals("outgoing", storedOriginal.debug.source);
 			assertEncryption(storedOriginal, originalExpected.encryption);
 
 			return persistence.getMessagesBefore(account, chatId, null);
@@ -867,16 +874,19 @@ class TestSqlite extends utest.Test {
 			Assert.equals(1, fetched.length);
 			final corrected = fetched[0];
 			Assert.equals(correctionExpected.text, corrected.text);
+			Assert.equals("mam", corrected.debug.source);
 			assertEncryption(corrected, correctionExpected.encryption);
 
 			final fetchedCorrection = corrected.versions.find(version -> version.localId == correctionVersion.localId);
 			Assert.notNull(fetchedCorrection);
 			Assert.equals(correctionExpected.text, fetchedCorrection.text);
+			Assert.equals("mam", fetchedCorrection.debug.source);
 			assertEncryption(fetchedCorrection, correctionExpected.encryption);
 
 			final fetchedOriginal = corrected.versions.find(version -> version.localId == original.localId);
 			Assert.notNull(fetchedOriginal);
 			Assert.equals(originalExpected.text, fetchedOriginal.text);
+			Assert.equals("outgoing", fetchedOriginal.debug.source);
 			assertEncryption(fetchedOriginal, originalExpected.encryption);
 
 			async.done();
@@ -2020,7 +2030,8 @@ class TestSqlite extends utest.Test {
 			"mam_by",
 			"sort_id",
 			"sync_point",
-			"json(encryption) AS encryption"
+			"json(encryption) AS encryption",
+			"json(debug) AS debug"
 		];
 		expected.sort(Reflect.compare);
 
@@ -2043,7 +2054,8 @@ class TestSqlite extends utest.Test {
 			"mam_by",
 			"sort_id",
 			"sync_point",
-			"json(encryption) AS encryption"
+			"json(encryption) AS encryption",
+			"json(debug) AS debug"
 		];
 
 		final expected = defaultColumns.concat(["stanza_id"]);
@@ -2070,7 +2082,8 @@ class TestSqlite extends utest.Test {
 			"mam_by",
 			"sort_id",
 			"sync_point",
-			"json(encryption) AS encryption"
+			"json(encryption) AS encryption",
+			"json(debug) AS debug"
 		];
 		final expected = defaultColumns.copy();
 		expected[defaultColumns.indexOf(columnToReplace)] = replacementSql;
diff --git a/test/persistence-tests.ts b/test/persistence-tests.ts
index 658b65f..66f908a 100644
--- a/test/persistence-tests.ts
+++ b/test/persistence-tests.ts
@@ -1,6 +1,40 @@
 import { expect } from "vitest";
 
 export function sharedPersistenceTests(test) {
+	test("message debug metadata round trips", async ({
+		borogove,
+		persistence,
+	}) => {
+		const builder = new borogove.ChatMessageBuilder({
+			serverId: "debug-message",
+			serverIdBy: "alice@example.com",
+			senderId: "hatter@example.com",
+			direction: 0,
+		});
+		builder.sortId = "debug-a0";
+		builder.to = borogove.JID.parse("alice@example.com");
+		builder.from = borogove.JID.parse("hatter@example.com");
+		builder.recipients = [builder.to];
+		builder.replyTo = [builder.from];
+		builder.debug = {
+			source: "push",
+			sortId: {
+				method: "between",
+				lower: "a0",
+				upper: null,
+			},
+		};
+
+		await persistence.storeMessages("alice@example.com", [builder.build()]);
+		const stored = await persistence.getMessage(
+			"alice@example.com",
+			"hatter@example.com",
+			"debug-message",
+			null,
+		);
+		expect(stored.debug).toEqual(builder.debug);
+	});
+
 	test("storeChats and getChats", async ({ borogove, persistence }) => {
 		const chat = new borogove.DirectChat(
 			null,