| author | Eric Roberts
<eric@devl.me> 2026-09-16 13:25:08 UTC |
| committer | Stephen Paul Weber
<singpolyma@singpolyma.net> 2026-09-16 14:06:46 UTC |
| parent | 241512a73d1f20d0eb32f7504023ba3c909df415 |
| 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,