git » sdk » commit cfae56f

Interleave MUC PMs

author Stephen Paul Weber
2026-04-08 05:37:34 UTC
committer Stephen Paul Weber
2026-04-08 05:43:28 UTC
parent 2a9ca8d1b8e69c18c1895b07cb2a1b61925f77af

Interleave MUC PMs

They won't be there by sortId, so we need to interleave them by timestamp

.gitignore +7 -0
Makefile +7 -1
borogove/persistence/IDB.js +79 -14
playwright.config.ts +52 -0
test/idb.spec.ts +491 -0

diff --git a/.gitignore b/.gitignore
index 788f7c1..f10dcde 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,3 +13,10 @@ haxedoc.xml
 cpp
 venv
 docs/js/borogove*.md
+
+# Playwright
+/test-results/
+/playwright-report/
+/blob-report/
+/playwright/.cache/
+/playwright/.auth/
diff --git a/Makefile b/Makefile
index b808b06..9348a1f 100644
--- a/Makefile
+++ b/Makefile
@@ -1,6 +1,6 @@
 HAXE_PATH=$$HOME/Software/haxe-4.3.1/hxnodejs/12,1,0/src
 
-.PHONY: all test doc hx-build-dep cpp/libborogove.dso npm/borogove-browser.js npm/borogove.js cpp
+.PHONY: all test doc hx-build-dep cpp/libborogove.dso npm/borogove-browser.js npm/borogove.js cpp playwright
 
 all: npm libborogove.batteriesincluded.so libborogove.so libborogove.a
 
@@ -49,6 +49,12 @@ npm: npm/borogove-browser.js npm/borogove.js borogove/persistence/IDB.js borogov
 	-cd npm && npx tsc --esModuleInterop --lib esnext,dom --target esnext --preserveConstEnums --allowJs --checkJs -d index.ts > /dev/null
 	cd npm && npx tsc --esModuleInterop --lib esnext,dom --target esnext --preserveConstEnums --allowJs --checkJs -d index.ts
 
+playwright/.cache/borogove.js: npm
+	esbuild npm/index.js --bundle --format=esm "--alias:node:dns=@xmpp/resolve" "--footer:js=export { borogove_JID as JID }" --outfile=$@
+
+playwright: playwright/.cache/borogove.js
+	npx playwright test
+
 cpp/libborogove.dso:
 	haxe cpp.hxml
 	$(RM) cpp/libborogove.dso.hash
diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js
index 0670d77..90dd466 100644
--- a/borogove/persistence/IDB.js
+++ b/borogove/persistence/IDB.js
@@ -184,8 +184,8 @@ export default async (dbname, media, tokenize, stemmer) => {
 				if (messagesIndexNames.contains("accounts")) {
 					tx.objectStore("messages").deleteIndex("accounts");
 				}
-				if (messagesIndexNames.contains("chats")) {
-					tx.objectStore("messages").deleteIndex("chats");
+				if (!messagesIndexNames.contains("chats")) {
+					tx.objectStore("messages").createIndex("chats", ["account", "chatId", "timestamp"]);
 				}
 			};
 			dbOpenReq.onsuccess = (event) => {
@@ -209,7 +209,7 @@ export default async (dbname, media, tokenize, stemmer) => {
 				}
 				const tx = db.transaction(["messages", "keyvaluepairs"], "readonly");
 				const messagesIndexNames = tx.objectStore("messages").indexNames;
-				const wantIndexNames = ["chatsBySortId", "accountsBySortId", "terms"];
+				const wantIndexNames = ["chatsBySortId", "accountsBySortId", "terms", "chats"];
 				for(const indexName of wantIndexNames) {
 					if(!messagesIndexNames.contains(indexName)) {
 						db.close();
@@ -230,7 +230,7 @@ export default async (dbname, media, tokenize, stemmer) => {
 						await promisifyRequest(writeKV.objectStore("keyvaluepairs").put(new Date(), "__migrationAddSortIdAndTerms"));
 					}
 
-					if (messagesIndexNames.contains("accounts") || messagesIndexNames.contains("chats")) {
+					if (messagesIndexNames.contains("accounts")) {
 						db.close();
 						openDb(db.version + 1).then(resolve, reject);
 						return;
@@ -679,27 +679,92 @@ export default async (dbname, media, tokenize, stemmer) => {
 		getMessagesBefore: async function(account, chatId, before) {
 			const tx = db.transaction(["messages"], "readonly");
 			const store = tx.objectStore("messages");
-			const cursor = store.index("chatsBySortId").openCursor(
-				IDBKeyRange.bound([account, chatId], [account, chatId, before?.sortId || []]),
-				"prev"
-			);
-			const messages = await this.getMessagesFromCursor(cursor, before);
+			const cursor = before?.type === enums.borogove_MessageType.MessageChannelPrivate ?
+				store.index("chats").openCursor(
+					IDBKeyRange.bound([account, chatId], [account, chatId, new Date(before.timestamp)]),
+					"prev"
+				) : store.index("chatsBySortId").openCursor(
+					IDBKeyRange.bound([account, chatId], [account, chatId, before?.sortId || []]),
+					"prev"
+				);
+			const messages = await this.getMessagesFromCursor(cursor, before, m => m.type === enums.borogove_MessageType.MessageChannelPrivate);
+
+			if (messages.length > 0 && messages[0].serverIdBy === chatId) {
+				const earliest = new Date(messages[messages.length - 1].timestamp);
+				const tx = db.transaction(["messages"], "readonly");
+				const store = tx.objectStore("messages");
+				const pmCursor = store.index("chats").openCursor(
+					IDBKeyRange.bound([account, chatId], [account, chatId, before ? new Date(before.timestamp) : []]),
+					"prev"
+				);
+				const promisePMs = [];
+				while (true) {
+					const cresult = await promisifyRequest(pmCursor);
+					if (!cresult?.value || cresult.value.timestamp < earliest) break;
+
+					if (cresult.value.type === enums.borogove_MessageType.MessageChannelPrivate && (!before || before.serverId !== cresult.value.serverId)) {
+						promisePMs.push(hydrateMessage(cresult.value));
+					}
+
+					cresult.continue();
+				}
+
+				const pms = await Promise.all(promisePMs);
+				for (const pm of pms) {
+					const idx = messages.findIndex(m => m.timestamp <= pm.timestamp);
+					if (idx >= 0) messages.splice(idx, 0, pm);
+				}
+			}
+
 			return messages.reverse();
 		},
 
 		getMessagesAfter: async function(account, chatId, after) {
-			const bound = after?.sortId ? [after.sortId] : [];
+			const index = after?.type === enums.borogove_MessageType.MessageChannelPrivate ? "chats" : "chatsBySortId";
+			const bound = after ? [after?.type === enums.borogove_MessageType.MessageChannelPrivate ? new Date(after.timestamp) : after.sortId] : [];
 			const tx = db.transaction(["messages"], "readonly");
 			const store = tx.objectStore("messages");
-			const cursor = store.index("chatsBySortId").openCursor(
+			const cursor = store.index(index).openCursor(
 				IDBKeyRange.bound([account, chatId, ...bound], [account, chatId, []]),
 				"next"
 			);
-			return this.getMessagesFromCursor(cursor, after);
+			const messages = await this.getMessagesFromCursor(cursor, after, m => m.type === enums.borogove_MessageType.MessageChannelPrivate);
+
+			if (messages.length > 0 && messages[0].serverIdBy === chatId) {
+				const latest = new Date(messages[messages.length - 1].timestamp);
+				const tx = db.transaction(["messages"], "readonly");
+				const store = tx.objectStore("messages");
+				const pmCursor = store.index("chats").openCursor(
+					IDBKeyRange.bound([account, chatId, ...(after ? [new Date(after.timestamp)] : [])], [account, chatId, []]),
+					"next"
+				);
+				const promisePMs = [];
+				while (true) {
+					const cresult = await promisifyRequest(pmCursor);
+					if (!cresult?.value) break;
+
+					if (cresult.value.type === enums.borogove_MessageType.MessageChannelPrivate && (!after || after.serverId !== cresult.value.serverId)) {
+						promisePMs.push(hydrateMessage(cresult.value));
+					}
+
+					if (cresult.value.timestamp > latest) break;
+
+					cresult.continue();
+				}
+
+				const pms = await Promise.all(promisePMs);
+				for (const pm of pms) {
+					const idx = messages.findLastIndex(m => m.timestamp < pm.timestamp);
+					if (idx >= 0) messages.splice(idx+1, 0, pm);
+				}
+			}
+
+			return messages;
 		},
 
 		getMessagesAround: async function(account, around) {
 			if (!around) throw "Cannot look around nothing";
+			if (around.type == enums.borogove_MessageType.MessageChannelPrivate) throw "Cannot look around PM";
 
 			const chatId = around.chatId();
 			const before = this.getMessagesBefore(account, chatId, around);
@@ -714,13 +779,13 @@ export default async (dbname, media, tokenize, stemmer) => {
 			return Promise.all([before, aroundAndAfter]).then(result => result.flat());
 		},
 
-		getMessagesFromCursor: async function(cursor, notIncluding) {
+		getMessagesFromCursor: async function(cursor, notIncluding, filter) {
 			const result = [];
 			while (true) {
 				const cresult = await promisifyRequest(cursor);
 				if (cresult && result.length < 50) {
 					const value = cresult.value;
-					if ((notIncluding?.serverId && notIncluding?.serverId === value?.serverId) || (notIncluding?.localId && !value?.serverId && notIncluding?.localId === value?.localId)) {
+					if ((notIncluding?.serverId && notIncluding?.serverId === value?.serverId) || (notIncluding?.localId && !value?.serverId && notIncluding?.localId === value?.localId) || (filter && filter(value))) {
 						cresult.continue();
 						continue;
 					}
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..86965cb
--- /dev/null
+++ b/playwright.config.ts
@@ -0,0 +1,52 @@
+import { defineConfig, devices } from "@playwright/test";
+
+/**
+ * Read environment variables from file.
+ * https://github.com/motdotla/dotenv
+ */
+// import dotenv from "dotenv";
+// import path from "path";
+// dotenv.config({ path: path.resolve(__dirname, ".env") });
+
+/**
+ * See https://playwright.dev/docs/test-configuration.
+ */
+export default defineConfig({
+	testDir: "./test",
+	/* Run tests in files in parallel */
+	fullyParallel: true,
+	/* Fail the build on CI if you accidentally left test.only in the source code. */
+	forbidOnly: !!process.env.CI,
+	/* Retry on CI only */
+	retries: process.env.CI ? 2 : 0,
+	/* Opt out of parallel tests on CI. */
+	workers: process.env.CI ? 1 : undefined,
+	/* Reporter to use. See https://playwright.dev/docs/test-reporters */
+	reporter: "dot",
+	/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
+	use: {
+		/* Base URL to use in actions like `await page.goto("")`. */
+		// baseURL: "http://localhost:3000",
+
+		/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
+		trace: "on-first-retry",
+	},
+
+	/* Configure projects for major browsers */
+	projects: [
+		{
+			name: "chromium",
+			use: { ...devices["Desktop Chrome"] },
+		},
+
+		{
+			name: "firefox",
+			use: { ...devices["Desktop Firefox"] },
+		},
+
+		{
+			name: "webkit",
+			use: { ...devices["Desktop Safari"] },
+		},
+	],
+});
diff --git a/test/idb.spec.ts b/test/idb.spec.ts
new file mode 100644
index 0000000..501994c
--- /dev/null
+++ b/test/idb.spec.ts
@@ -0,0 +1,491 @@
+import { test, expect } from "@playwright/test";
+import fs from "fs";
+
+test("1:1 come back ordered by sortId", async ({ page }) => {
+	page.route("https://localhost/", route => route.fulfill({ body: "<html></html>" }));
+	const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+	await page.goto("https://localhost/");
+	const result = await page.evaluate(async (code) => {
+		const blob = new Blob([code], { type: 'text/javascript' });
+		const borogove = await import(URL.createObjectURL(blob));
+
+		const mediaStore = borogove.persistence.MediaStoreCache("snikket");
+		const persistence = await borogove.persistence.IDB("snikket", mediaStore);
+
+		const builder = new borogove.ChatMessageBuilder({
+			serverId: "1",
+			serverIdBy: "alice@example.com",
+			senderId: "hatter@example.com",
+			direction: 0,
+		});
+		builder.sortId = "a0";
+		builder.to = borogove.JID.parse("alice@example.com");
+		builder.from = borogove.JID.parse("hatter@example.com");
+		builder.replyTo = [builder.from];
+
+		const builder2 = new borogove.ChatMessageBuilder({
+			serverId: "2",
+			serverIdBy: "alice@example.com",
+			senderId: "hatter@example.com",
+			direction: 0,
+		});
+		builder2.sortId = "b0";
+		builder2.to = borogove.JID.parse("alice@example.com");
+		builder2.from = borogove.JID.parse("hatter@example.com");
+		builder2.replyTo = [builder.from];
+
+		await persistence.storeMessages("alice@example.com", [
+			builder2.build(),
+			builder.build(),
+		]);
+
+		return await persistence.getMessagesBefore("alice@example.com", "hatter@example.com");
+	}, code);
+
+	expect(result.length).toBe(2);
+	expect(result[0].serverId).toBe("1");
+	expect(result[1].serverId).toBe("2");
+});
+
+test("getMessagesBefore the end: MUC come back ordered by sortId, PM by timestamp", async ({ page }) => {
+	page.route("https://localhost/", route => route.fulfill({ body: "<html></html>" }));
+	const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+	await page.goto("https://localhost/");
+	const result = await page.evaluate(async (code) => {
+		const blob = new Blob([code], { type: 'text/javascript' });
+		const borogove = await import(URL.createObjectURL(blob));
+
+		const mediaStore = borogove.persistence.MediaStoreCache("snikket");
+		const persistence = await borogove.persistence.IDB("snikket", mediaStore);
+
+		const builder = new borogove.ChatMessageBuilder({
+			serverId: "1",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannel,
+			timestamp: "2020-01-01T00:00:01Z",
+		});
+		builder.sortId = "a0";
+		builder.to = borogove.JID.parse("alice@example.com");
+		builder.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder.replyTo = [builder.from.asBare()];
+
+		const builder2 = new borogove.ChatMessageBuilder({
+			serverId: "2",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannel,
+			timestamp: "2020-01-01T00:00:00Z",
+		});
+		builder2.sortId = "b0";
+		builder2.to = borogove.JID.parse("alice@example.com");
+		builder2.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder2.replyTo = [builder.from.asBare()];
+
+		const builder3 = new borogove.ChatMessageBuilder({
+			serverId: "3",
+			serverIdBy: "alice@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannelPrivate,
+			timestamp: "2020-01-01T00:00:03Z",
+		});
+		builder3.sortId = "a0";
+		builder3.to = borogove.JID.parse("alice@example.com");
+		builder3.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder3.replyTo = [builder.from.asBare()];
+
+		await persistence.storeMessages("alice@example.com", [
+			builder2.build(),
+			builder3.build(),
+			builder.build(),
+		]);
+
+		return await persistence.getMessagesBefore("alice@example.com", "teaparty@example.com");
+	}, code);
+
+	expect(result.length).toBe(3);
+	expect(result[0].serverId).toBe("1");
+	expect(result[1].serverId).toBe("2");
+	expect(result[2].serverId).toBe("3");
+});
+
+test("getMessagesBefore some point: MUC come back ordered by sortId, PM by timestamp", async ({ page }) => {
+	page.route("https://localhost/", route => route.fulfill({ body: "<html></html>" }));
+	const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+	await page.goto("https://localhost/");
+	const result = await page.evaluate(async (code) => {
+		const blob = new Blob([code], { type: 'text/javascript' });
+		const borogove = await import(URL.createObjectURL(blob));
+
+		const mediaStore = borogove.persistence.MediaStoreCache("snikket");
+		const persistence = await borogove.persistence.IDB("snikket", mediaStore);
+
+		const builder = new borogove.ChatMessageBuilder({
+			serverId: "1",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannel,
+			timestamp: "2020-01-01T00:00:01Z",
+		});
+		builder.sortId = "a0";
+		builder.to = borogove.JID.parse("alice@example.com");
+		builder.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder.replyTo = [builder.from.asBare()];
+
+		const builder2 = new borogove.ChatMessageBuilder({
+			serverId: "2",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannel,
+			timestamp: "2020-01-01T00:00:00Z",
+		});
+		builder2.sortId = "b0";
+		builder2.to = borogove.JID.parse("alice@example.com");
+		builder2.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder2.replyTo = [builder.from.asBare()];
+
+		const builder3 = new borogove.ChatMessageBuilder({
+			serverId: "3",
+			serverIdBy: "alice@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannelPrivate,
+			timestamp: "2020-01-01T00:00:03Z",
+		});
+		builder3.sortId = "Z~";
+		builder3.to = borogove.JID.parse("alice@example.com");
+		builder3.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder3.replyTo = [builder.from.asBare()];
+
+		const builder4 = new borogove.ChatMessageBuilder({
+			serverId: "4",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannel,
+			timestamp: "2020-01-01T00:00:04Z",
+		});
+		builder4.sortId = "c0";
+		builder4.to = borogove.JID.parse("alice@example.com");
+		builder4.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder4.replyTo = [builder.from.asBare()];
+
+		await persistence.storeMessages("alice@example.com", [
+			builder2.build(),
+			builder4.build(),
+			builder3.build(),
+			builder.build(),
+		]);
+
+		return await persistence.getMessagesBefore("alice@example.com", "teaparty@example.com", builder4.build());
+	}, code);
+
+	expect(result.length).toBe(3);
+	expect(result[0].serverId).toBe("1");
+	expect(result[1].serverId).toBe("2");
+	expect(result[2].serverId).toBe("3");
+});
+
+test("getMessagesBefore a PM", async ({ page }) => {
+	page.route("https://localhost/", route => route.fulfill({ body: "<html></html>" }));
+	const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+	await page.goto("https://localhost/");
+	const result = await page.evaluate(async (code) => {
+		const blob = new Blob([code], { type: 'text/javascript' });
+		const borogove = await import(URL.createObjectURL(blob));
+
+		const mediaStore = borogove.persistence.MediaStoreCache("snikket");
+		const persistence = await borogove.persistence.IDB("snikket", mediaStore);
+
+		const builder = new borogove.ChatMessageBuilder({
+			serverId: "1",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannel,
+			timestamp: "2020-01-01T00:00:00Z",
+		});
+		builder.sortId = "a0";
+		builder.to = borogove.JID.parse("alice@example.com");
+		builder.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder.replyTo = [builder.from.asBare()];
+
+		const builder2 = new borogove.ChatMessageBuilder({
+			serverId: "2",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannel,
+			timestamp: "2020-01-01T00:00:01Z",
+		});
+		builder2.sortId = "b0";
+		builder2.to = borogove.JID.parse("alice@example.com");
+		builder2.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder2.replyTo = [builder.from.asBare()];
+
+		const builder3 = new borogove.ChatMessageBuilder({
+			serverId: "3",
+			serverIdBy: "alice@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannelPrivate,
+			timestamp: "2020-01-01T00:00:03Z",
+		});
+		builder3.sortId = "Z~";
+		builder3.to = borogove.JID.parse("alice@example.com");
+		builder3.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder3.replyTo = [builder.from.asBare()];
+
+		const builder4 = new borogove.ChatMessageBuilder({
+			serverId: "4",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannel,
+			timestamp: "2020-01-01T00:00:04Z",
+		});
+		builder4.sortId = "c0";
+		builder4.to = borogove.JID.parse("alice@example.com");
+		builder4.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder4.replyTo = [builder.from.asBare()];
+
+		await persistence.storeMessages("alice@example.com", [
+			builder2.build(),
+			builder4.build(),
+			builder3.build(),
+			builder.build(),
+		]);
+
+		return await persistence.getMessagesBefore("alice@example.com", "teaparty@example.com", builder3.build());
+	}, code);
+
+	expect(result.length).toBe(2);
+	expect(result[0].serverId).toBe("1");
+	expect(result[1].serverId).toBe("2");
+});
+
+test("getMessagesAfter the start: MUC come back ordered by sortId, PM by timestamp", async ({ page }) => {
+	page.route("https://localhost/", route => route.fulfill({ body: "<html></html>" }));
+	const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+	await page.goto("https://localhost/");
+	const result = await page.evaluate(async (code) => {
+		const blob = new Blob([code], { type: 'text/javascript' });
+		const borogove = await import(URL.createObjectURL(blob));
+
+		const mediaStore = borogove.persistence.MediaStoreCache("snikket");
+		const persistence = await borogove.persistence.IDB("snikket", mediaStore);
+
+		const builder = new borogove.ChatMessageBuilder({
+			serverId: "1",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannel,
+			timestamp: "2020-01-01T00:00:00Z",
+		});
+		builder.sortId = "a0";
+		builder.to = borogove.JID.parse("alice@example.com");
+		builder.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder.replyTo = [builder.from.asBare()];
+
+		const builder2 = new borogove.ChatMessageBuilder({
+			serverId: "2",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannel,
+			timestamp: "2020-01-01T00:00:01Z",
+		});
+		builder2.sortId = "b0";
+		builder2.to = borogove.JID.parse("alice@example.com");
+		builder2.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder2.replyTo = [builder.from.asBare()];
+
+		const builder3 = new borogove.ChatMessageBuilder({
+			serverId: "3",
+			serverIdBy: "alice@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannelPrivate,
+			timestamp: "2020-01-01T00:00:03Z",
+		});
+		builder3.sortId = "a1";
+		builder3.to = borogove.JID.parse("alice@example.com");
+		builder3.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder3.replyTo = [builder.from.asBare()];
+
+		await persistence.storeMessages("alice@example.com", [
+			builder2.build(),
+			builder3.build(),
+			builder.build(),
+		]);
+
+		return await persistence.getMessagesAfter("alice@example.com", "teaparty@example.com");
+	}, code);
+
+	expect(result.length).toBe(3);
+	expect(result[0].serverId).toBe("1");
+	expect(result[1].serverId).toBe("2");
+	expect(result[2].serverId).toBe("3");
+});
+
+test("getMessagesAfter some point: MUC come back ordered by sortId, PM by timestamp", async ({ page }) => {
+	page.route("https://localhost/", route => route.fulfill({ body: "<html></html>" }));
+	const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+	await page.goto("https://localhost/");
+	const result = await page.evaluate(async (code) => {
+		const blob = new Blob([code], { type: 'text/javascript' });
+		const borogove = await import(URL.createObjectURL(blob));
+
+		const mediaStore = borogove.persistence.MediaStoreCache("snikket");
+		const persistence = await borogove.persistence.IDB("snikket", mediaStore);
+
+		const builder = new borogove.ChatMessageBuilder({
+			serverId: "1",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannel,
+			timestamp: "2020-01-01T00:00:01Z",
+		});
+		builder.sortId = "a0";
+		builder.to = borogove.JID.parse("alice@example.com");
+		builder.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder.replyTo = [builder.from.asBare()];
+
+		const builder2 = new borogove.ChatMessageBuilder({
+			serverId: "2",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannel,
+			timestamp: "2020-01-01T00:00:00Z",
+		});
+		builder2.sortId = "b0";
+		builder2.to = borogove.JID.parse("alice@example.com");
+		builder2.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder2.replyTo = [builder.from.asBare()];
+
+		const builder3 = new borogove.ChatMessageBuilder({
+			serverId: "3",
+			serverIdBy: "alice@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannelPrivate,
+			timestamp: "2020-01-01T00:00:03Z",
+		});
+		builder3.sortId = "Z~";
+		builder3.to = borogove.JID.parse("alice@example.com");
+		builder3.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder3.replyTo = [builder.from.asBare()];
+
+		const builder4 = new borogove.ChatMessageBuilder({
+			serverId: "4",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannel,
+			timestamp: "2020-01-01T00:00:04Z",
+		});
+		builder4.sortId = "c0";
+		builder4.to = borogove.JID.parse("alice@example.com");
+		builder4.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder4.replyTo = [builder.from.asBare()];
+
+		await persistence.storeMessages("alice@example.com", [
+			builder2.build(),
+			builder4.build(),
+			builder3.build(),
+			builder.build(),
+		]);
+
+		return await persistence.getMessagesAfter("alice@example.com", "teaparty@example.com", builder.build());
+	}, code);
+
+	expect(result.length).toBe(3);
+	expect(result[0].serverId).toBe("2");
+	expect(result[1].serverId).toBe("3");
+	expect(result[2].serverId).toBe("4");
+});
+
+test("getMessagesAfter a PM", async ({ page }) => {
+	page.route("https://localhost/", route => route.fulfill({ body: "<html></html>" }));
+	const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+	await page.goto("https://localhost/");
+	const result = await page.evaluate(async (code) => {
+		const blob = new Blob([code], { type: 'text/javascript' });
+		const borogove = await import(URL.createObjectURL(blob));
+
+		const mediaStore = borogove.persistence.MediaStoreCache("snikket");
+		const persistence = await borogove.persistence.IDB("snikket", mediaStore);
+
+		const builder = new borogove.ChatMessageBuilder({
+			serverId: "1",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannel,
+			timestamp: "2020-01-01T00:00:00Z",
+		});
+		builder.sortId = "a0";
+		builder.to = borogove.JID.parse("alice@example.com");
+		builder.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder.replyTo = [builder.from.asBare()];
+
+		const builder2 = new borogove.ChatMessageBuilder({
+			serverId: "2",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannel,
+			timestamp: "2020-01-01T00:00:01Z",
+		});
+		builder2.sortId = "b0";
+		builder2.to = borogove.JID.parse("alice@example.com");
+		builder2.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder2.replyTo = [builder.from.asBare()];
+
+		const builder3 = new borogove.ChatMessageBuilder({
+			serverId: "3",
+			serverIdBy: "alice@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannelPrivate,
+			timestamp: "2020-01-01T00:00:03Z",
+		});
+		builder3.sortId = "Z~";
+		builder3.to = borogove.JID.parse("alice@example.com");
+		builder3.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder3.replyTo = [builder.from.asBare()];
+
+		const builder4 = new borogove.ChatMessageBuilder({
+			serverId: "4",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: 0,
+			type: borogove.MessageType.MessageChannel,
+			timestamp: "2020-01-01T00:00:04Z",
+		});
+		builder4.sortId = "c0";
+		builder4.to = borogove.JID.parse("alice@example.com");
+		builder4.from = borogove.JID.parse("teaparty@example.com/hatter");
+		builder4.replyTo = [builder.from.asBare()];
+
+		await persistence.storeMessages("alice@example.com", [
+			builder2.build(),
+			builder4.build(),
+			builder3.build(),
+			builder.build(),
+		]);
+
+		return await persistence.getMessagesAfter("alice@example.com", "teaparty@example.com", builder3.build());
+	}, code);
+
+	expect(result.length).toBe(1);
+	expect(result[0].serverId).toBe("4");
+});