git » sdk » commit 8bf9452

Run most tests under nodejs as well

author Stephen Paul Weber
2026-05-05 04:11:25 UTC
committer Stephen Paul Weber
2026-05-05 04:15:43 UTC
parent 56ee845da7f5b211ca0713609ef25751ea5f3217

Run most tests under nodejs as well

Implement sqlite driver for nodejs, fix some bugs found by tests

.gitignore +1 -0
Makefile +5 -1
borogove/Util.hx +22 -0
borogove/XEP0393.hx +1 -1
borogove/persistence/SqliteDriver.js.hx +93 -3
nodejs.hxml +2 -1
test/TestAll.hx +9 -4
test/TestChatMessageBuilder.hx +2 -2
test/TestSessionDescription.hx +1 -1
test/TestSqlite.hx +847 -0
testjs.hxml +26 -0

diff --git a/.gitignore b/.gitignore
index f10dcde..c6137e0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,6 +13,7 @@ haxedoc.xml
 cpp
 venv
 docs/js/borogove*.md
+.cache/
 
 # Playwright
 /test-results/
diff --git a/Makefile b/Makefile
index 5dbc206..a5f0e6f 100644
--- a/Makefile
+++ b/Makefile
@@ -1,12 +1,16 @@
 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 playwright
+.PHONY: all test doc hx-build-dep cpp/libborogove.dso npm/borogove-browser.js npm/borogove.js cpp playwright ci
 
 all: npm libborogove.batteriesincluded.so libborogove.so libborogove.a
 
 test:
 	haxe test.hxml
 
+ci: test playwright
+	mkdir .cache
+	haxe testjs.hxml
+
 hx-build-dep:
 	haxelib --quiet git jsImport https://github.com/back2dos/jsImport
 	haxelib --quiet install datetime
diff --git a/borogove/Util.hx b/borogove/Util.hx
index 26cd50b..b3b1bea 100644
--- a/borogove/Util.hx
+++ b/borogove/Util.hx
@@ -7,6 +7,28 @@ import js.html.TextEncoder;
 final textEncoder = new TextEncoder();
 #end
 
+#if macro
+import haxe.macro.Compiler;
+import haxe.macro.Context.*;
+
+using haxe.io.Path;
+using StringTools;
+using sys.io.File;
+using sys.FileSystem;
+
+class DummyRequireMacro {
+	static final META = ':dummyRequire';
+	static function init() {
+		onGenerate(types -> {
+			var tmp = Compiler.getOutput().directory() + '/tmp${Std.random(1 << 29)}.js';
+			tmp.saveContent("if (typeof(require) === 'undefined') globalThis.require = (){};");
+			Compiler.includeFile(tmp);
+			onAfterGenerate(tmp.deleteFile);
+		});
+	}
+}
+#end
+
 function setupTrace() {
 #if js
 	haxe.Log.trace = (v, ?infos) -> {
diff --git a/borogove/XEP0393.hx b/borogove/XEP0393.hx
index 3a7f742..a972e99 100644
--- a/borogove/XEP0393.hx
+++ b/borogove/XEP0393.hx
@@ -123,7 +123,7 @@ class XEP0393 {
 		}
 
 		if (xhtml.name == "blockquote") {
-			return ~/^/gm.replace(s.toString(), "> ") + "\n";
+			return ~/^|(?<=\n)(?!$)/g.replace(s.toString(), "> ") + "\n";
 		}
 
 		return s.toString();
diff --git a/borogove/persistence/SqliteDriver.js.hx b/borogove/persistence/SqliteDriver.js.hx
index 58530bf..4dd192f 100644
--- a/borogove/persistence/SqliteDriver.js.hx
+++ b/borogove/persistence/SqliteDriver.js.hx
@@ -2,17 +2,107 @@ package borogove.persistence;
 
 import haxe.io.Bytes;
 import thenshim.Promise;
+using StringTools;
 
 typedef Promiser = (String, Dynamic) -> Promise<Dynamic>;
 
 #if nodejs
+@:js.import("node:worker_threads", "Worker")
+extern class Worker {
+	public function new(code: String, options: { eval: Bool, workerData: Any });
+	public function postMessage(data: Any): Void;
+	public function on(event: String, handler: (Dynamic) -> Void): Void;
+}
+
 class SqliteDriver {
+	static inline final WORKER = '
+		import { workerData, parentPort } from "node:worker_threads";
+		import { DatabaseSync } from "node:sqlite";
+		const db = new DatabaseSync(workerData.dbfile);
+
+		if (workerData.writer) {
+			db.exec("PRAGMA journal_mode=WAL");
+			db.exec("PRAGMA synchronous=NORMAL");
+			db.exec("PRAGMA temp_store=2");
+		}
+
+		parentPort.on("message", ({ id, qs }) => {
+			const lastQ = qs.pop();
+			if (qs.length > 0) db.exec("BEGIN TRANSACTION");
+			try {
+				for (const q of qs) {
+					db.exec(q);
+				}
+				parentPort.postMessage({ id, result: db.prepare(lastQ).all() });
+				if (qs.length > 0) db.exec("COMMIT");
+			} catch (error) {
+				if (qs.length > 0) db.exec("ROLLBACK");
+				parentPort.postMessage({ id, error });
+			}
+		});
+	';
+	private final writePool: Array<Worker> = [];
+	private var readPool: Array<Worker> = [];
+	private final pending: Map<Int, { resolve: (Array<Dynamic>) -> Void, reject: (Any) -> Void }> = new Map();
+	private final ready: Promise<Bool>;
+	private var setReady: (Bool)->Void;
+	private var reqId = 0;
+
+	private function mkWorker(dbfile: String, writer: Bool) {
+		final worker = new Worker(WORKER, { eval: true, workerData: { dbfile: dbfile, writer: writer } });
+		worker.on("message", (data: { id: Int, ?result: Array<Dynamic>, ?error: Any}) -> {
+			if (data.error != null) {
+				pending[data.id].reject(data.error);
+			} else {
+				pending[data.id].resolve(data.result);
+			}
+			pending.remove(data.id);
+		});
+		return worker;
+	}
+
 	public function new(dbfile: String, migrate: (Array<String>->Promise<haxe.iterators.ArrayIterator<Dynamic>>)->Promise<Any>) {
-		throw "TODO";
+		ready = new Promise((resolve, reject) -> setReady = resolve);
+
+		writePool.push(mkWorker(dbfile, true));
+		if (~/:memory:|mode=memory/.match(dbfile)) {
+			readPool = writePool;
+		} else {
+			for (i in 0...10) {
+				readPool.push(mkWorker(dbfile, false));
+			}
+		}
+
+		migrate((sql) -> this.execute(writePool, sql.map(q -> { sql: q, params: [] }))).then(_ -> {
+			setReady(true);
+		});
+	}
+
+	private function execute(pool: Array<Worker>, qs: Array<{ sql: String, ?params: Array<Dynamic> }>): Promise<haxe.iterators.ArrayIterator<Dynamic>> {
+		final worker = pool.pop();
+		if (worker == null) {
+			return new Promise((resolve, reject) -> haxe.Timer.delay(() -> resolve(null), 10)).then(_ ->
+				execute(pool, qs)
+			);
+		}
+		final id = reqId++;
+		final promise = new Promise((resolve, reject) -> pending[id] = { resolve: resolve, reject: reject });
+		worker.postMessage({ id: id, qs: qs.map(q -> Sqlite.prepare(q)) });
+		return promise.then(result -> {
+			pool.push(worker);
+			return result.iterator();
+		});
+	}
+
+	public function execMany(qs: Array<{ sql: String, ?params: Array<Dynamic> }>): Promise<haxe.iterators.ArrayIterator<Dynamic>> {
+		return ready.then(_ -> {
+			final pool = StringTools.startsWith(qs[0].sql, "SELECT") ? readPool : writePool;
+			return execute(pool, qs);
+		});
 	}
 
-	public function exec(sql: haxe.extern.EitherType<String, Array<String>>, ?params: Array<Dynamic>): Promise<haxe.iterators.ArrayIterator<Dynamic>> {
-		throw "TODO";
+	public function exec(sql: String, ?params: Array<Dynamic>) {
+		return execMany([{ sql: sql, params: params }]);
 	}
 }
 #else
diff --git a/nodejs.hxml b/nodejs.hxml
index e9108ee..058d4bc 100644
--- a/nodejs.hxml
+++ b/nodejs.hxml
@@ -1,5 +1,6 @@
 --library HtmlParser
 --library datetime
+--library fractional-indexing
 --library haxe-strings
 --library hsluv
 --library hxnodejs
@@ -8,7 +9,6 @@
 --library thenshim
 --library tink_http
 --library uuidv7
---library fractional-indexing
 
 borogove.Client
 borogove.Register
@@ -17,6 +17,7 @@ borogove.Version
 borogove.persistence.Sqlite
 borogove.Html
 
+--macro borogove.Util.DummyRequireMacro.init()
 -D analyzer-optimize
 -D js-es=6
 -D hxtsdgen_enums_ts
diff --git a/test/TestAll.hx b/test/TestAll.hx
index 6c8dc1f..64a413c 100644
--- a/test/TestAll.hx
+++ b/test/TestAll.hx
@@ -6,25 +6,30 @@ import utest.ui.Report;
 class TestAll {
 	public static function main() {
 		utest.UTest.run([
-			new TestPresence(),
 			new TestCapsRepo(),
 			new TestChatMessage(),
 			new TestSessionDescription(),
 			new TestChatMessageBuilder(),
 			new TestStanza(),
+#if !nodejs
 			new TestCaps(),
+			new TestPresence(),
 			new TestClient(),
+			new TestSortId(),
+			new TestParticipant(),
+			new TestChat(),
+#end
 			new TestXEP0393(),
 			new TestEmojiUtil(),
 			new TestJID(),
 			new TestStringUtil(),
 			new TestUtil(),
 			new TestReaction(),
-			new TestSortId(),
 			new TestHtml(),
-			new TestChat(),
 			new TestStatus(),
-			new TestParticipant(),
+#if !eval
+			new TestSqlite(),
+#end
 		]);
 	}
 }
diff --git a/test/TestChatMessageBuilder.hx b/test/TestChatMessageBuilder.hx
index 17a3777..c4c57c9 100644
--- a/test/TestChatMessageBuilder.hx
+++ b/test/TestChatMessageBuilder.hx
@@ -20,9 +20,9 @@ class TestChatMessageBuilder extends utest.Test {
 
 	public function testConvertHtmlToText() {
 		final msg = new ChatMessageBuilder();
-		msg.setBody(Html.fromString("<blockquote>Hello<br>you</blockquote><img alt=':boop:'><br><b>hi</b> <em>hi</em> <s>hey</s> <tt>up</tt><pre>hello<br>you"));
+		msg.setBody(Html.fromString("<blockquote>Hello<br><br>you</blockquote><img alt=':boop:'><br><b>hi</b> <em>hi</em> <s>hey</s> <tt>up</tt><pre>hello<br>you"));
 		Assert.equals(
-			"> Hello\n> you\n\n:boop:\n*hi* _hi_ ~hey~ `up`\n```\nhello\nyou\n```",
+			"> Hello\n> \n> you\n\n:boop:\n*hi* _hi_ ~hey~ `up`\n```\nhello\nyou\n```",
 			msg.text
 		);
 	}
diff --git a/test/TestSessionDescription.hx b/test/TestSessionDescription.hx
index fdc327a..9cc0bd8 100644
--- a/test/TestSessionDescription.hx
+++ b/test/TestSessionDescription.hx
@@ -87,7 +87,7 @@ class TestSessionDescription extends utest.Test {
 		"a=ssrc:691851057 cname:{df71a836-615d-4bab-bf3e-7f2ee9d2f0a1}\r\n";
 
 	public function testConvertStanzaToSDP() {
-		final session = SessionDescription.fromStanza(Stanza.fromXml(Xml.parse(stanzaSource)), false);
+		final session = SessionDescription.fromStanza(Stanza.parse(stanzaSource), false);
 		Assert.equals(sdpExample, session.toSdp());
 	}
 
diff --git a/test/TestSqlite.hx b/test/TestSqlite.hx
new file mode 100644
index 0000000..5853e4e
--- /dev/null
+++ b/test/TestSqlite.hx
@@ -0,0 +1,847 @@
+package test;
+
+import haxe.io.Bytes;
+import haxe.io.BytesData;
+import thenshim.Promise;
+import thenshim.PromiseTools;
+import utest.Assert;
+import utest.Async;
+
+import borogove.persistence.Sqlite;
+import borogove.persistence.MediaStore;
+import borogove.persistence.KeyValueStore;
+import borogove.ChatMessageBuilder;
+import borogove.JID;
+import borogove.ID;
+import borogove.Message;
+import borogove.Chat;
+import borogove.Status;
+import borogove.Reaction;
+import borogove.ReactionUpdate;
+import borogove.Html;
+import borogove.Hash;
+
+using Lambda;
+using thenshim.PromiseTools;
+
+@:access(borogove)
+class MockMediaStore implements MediaStore {
+	private var kv: Null<KeyValueStore> = null;
+
+	public function new() { }
+
+	@:allow(borogove)
+	private function setKV(kv: KeyValueStore) {
+		this.kv = kv;
+	}
+
+	public function getMediaPath(uri: String): Promise<Null<String>> {
+		final hash = Hash.fromUri(uri);
+		if (hash.algorithm == "sha-256") {
+			return kv.get(hash.serializeUri()).then(v ->
+				Promise.resolve(v == null ? null : hash.serializeUri())
+			);
+		} else {
+			return kv.get(hash.serializeUri()).then(sha256uri -> {
+				final sha256 = sha256uri == null ? null : Hash.fromUri(sha256uri);
+				if (sha256 == null) {
+					return Promise.resolve(null);
+				} else {
+					return getMediaPath(sha256.toUri());
+				}
+			});
+		}
+	}
+
+	public function hasMedia(hashAlgorithm:String, hash:BytesData): Promise<Bool> {
+		final hash = new Hash(hashAlgorithm, hash);
+		return getMediaPath(hash.toUri()).then(path -> path != null);
+	}
+
+	public function removeMedia(hashAlgorithm: String, hash: BytesData) {
+		final hash = new Hash(hashAlgorithm, hash);
+		getMediaPath(hash.toUri()).then(p -> kv.set(p, null));
+	}
+
+	public function storeMedia(mime: String, bd: BytesData): Promise<Bool> {
+		final bytes = Bytes.ofData(bd);
+		final sha1 = Hash.sha1(bytes);
+		final sha256 = Hash.sha256(bytes);
+		return thenshim.PromiseTools.all([
+			kv.set(sha1.serializeUri(), sha256.serializeUri()),
+			kv.set(sha256.serializeUri(), mime)
+		]).then(_ -> true);
+	}
+}
+
+@:access(borogove)
+class TestSqlite extends utest.Test {
+	var persistence: Sqlite;
+	var mediaStore: MockMediaStore;
+
+	public function setup() {
+		mediaStore = new MockMediaStore();
+		persistence = new Sqlite("file:" + ID.unique() + "?mode=memory&cache=shared", mediaStore);
+	}
+
+	public function testOrder(async: Async) {
+		final account = "alice@example.com";
+		final builder = new ChatMessageBuilder({
+			serverId: "1",
+			serverIdBy: "alice@example.com",
+			senderId: "hatter@example.com",
+			direction: MessageReceived,
+		});
+		builder.sortId = "a0";
+		builder.to = JID.parse("alice@example.com");
+		builder.from = JID.parse("hatter@example.com");
+		builder.recipients = [builder.to];
+		builder.replyTo = [builder.from];
+
+		final builder2 = new ChatMessageBuilder({
+			serverId: "2",
+			serverIdBy: "alice@example.com",
+			senderId: "hatter@example.com",
+			direction: MessageReceived,
+		});
+		builder2.sortId = "b0";
+		builder2.to = JID.parse("alice@example.com");
+		builder2.from = JID.parse("hatter@example.com");
+		builder2.recipients = [builder2.to];
+		builder2.replyTo = [builder2.from];
+
+		persistence.storeMessages(account, [
+			builder2.build(),
+			builder.build(),
+		]).then(_ -> {
+			return persistence.getMessagesBefore(account, "hatter@example.com", null);
+		}).then(result -> {
+			Assert.equals(2, result.length);
+			Assert.equals("1", result[0].serverId);
+			Assert.equals("2", result[1].serverId);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testMessagesBefore(async: Async) {
+		final account = "alice@example.com";
+		final builder = new ChatMessageBuilder({
+			serverId: "1",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannel,
+			timestamp: "2020-01-01T00:00:01Z",
+		});
+		builder.sortId = "a0";
+		builder.to = JID.parse("alice@example.com");
+		builder.from = JID.parse("teaparty@example.com/hatter");
+		builder.replyTo = [builder.from.asBare()];
+
+		final builder2 = new ChatMessageBuilder({
+			serverId: "2",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannel,
+			timestamp: "2020-01-01T00:00:00Z",
+		});
+		builder2.sortId = "b0";
+		builder2.to = JID.parse("alice@example.com");
+		builder2.from = JID.parse("teaparty@example.com/hatter");
+		builder2.replyTo = [builder2.from.asBare()];
+
+		final builder3 = new ChatMessageBuilder({
+			serverId: "3",
+			serverIdBy: "alice@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannelPrivate,
+			timestamp: "2020-01-01T00:00:03Z",
+		});
+		builder3.sortId = "a0";
+		builder3.to = JID.parse("alice@example.com");
+		builder3.from = JID.parse("teaparty@example.com/hatter");
+		builder3.replyTo = [builder3.from.asBare()];
+
+		persistence.storeMessages(account, [
+			builder2.build(),
+			builder3.build(),
+			builder.build(),
+		]).then(_ -> {
+			return persistence.getMessagesBefore(account, "teaparty@example.com", null);
+		}).then(result -> {
+			Assert.equals(3, result.length);
+			Assert.equals("1", result[0].serverId);
+			Assert.equals("2", result[1].serverId);
+			Assert.equals("3", result[2].serverId);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testMessagesBeforePoint(async: Async) {
+		final account = "alice@example.com";
+		final builder = new ChatMessageBuilder({
+			serverId: "1",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannel,
+			timestamp: "2020-01-01T00:00:01Z",
+		});
+		builder.sortId = "a0";
+		builder.to = JID.parse("alice@example.com");
+		builder.from = JID.parse("teaparty@example.com/hatter");
+		builder.replyTo = [builder.from.asBare()];
+
+		final builder2 = new ChatMessageBuilder({
+			serverId: "2",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannel,
+			timestamp: "2020-01-01T00:00:00Z",
+		});
+		builder2.sortId = "b0";
+		builder2.to = JID.parse("alice@example.com");
+		builder2.from = JID.parse("teaparty@example.com/hatter");
+		builder2.replyTo = [builder2.from.asBare()];
+
+		final builder3 = new ChatMessageBuilder({
+			serverId: "3",
+			serverIdBy: "alice@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannelPrivate,
+			timestamp: "2020-01-01T00:00:03Z",
+		});
+		builder3.sortId = "Z~";
+		builder3.to = JID.parse("alice@example.com");
+		builder3.from = JID.parse("teaparty@example.com/hatter");
+		builder3.replyTo = [builder3.from.asBare()];
+
+		final builder4 = new ChatMessageBuilder({
+			serverId: "4",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannel,
+			timestamp: "2020-01-01T00:00:04Z",
+		});
+		builder4.sortId = "c0";
+		builder4.to = JID.parse("alice@example.com");
+		builder4.from = JID.parse("teaparty@example.com/hatter");
+		builder4.replyTo = [builder4.from.asBare()];
+
+		persistence.storeMessages(account, [
+			builder2.build(),
+			builder4.build(),
+			builder3.build(),
+			builder.build(),
+		]).then(_ -> {
+			return persistence.getMessagesBefore(account, "teaparty@example.com", builder4.build());
+		}).then(result -> {
+			Assert.equals(3, result.length);
+			Assert.equals("1", result[0].serverId);
+			Assert.equals("2", result[1].serverId);
+			Assert.equals("3", result[2].serverId);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testMessagesBeforePM(async: Async) {
+		final account = "alice@example.com";
+		final builder = new ChatMessageBuilder({
+			serverId: "1",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannel,
+			timestamp: "2020-01-01T00:00:00Z",
+		});
+		builder.sortId = "a0";
+		builder.to = JID.parse("alice@example.com");
+		builder.from = JID.parse("teaparty@example.com/hatter");
+		builder.replyTo = [builder.from.asBare()];
+
+		final builder2 = new ChatMessageBuilder({
+			serverId: "2",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannel,
+			timestamp: "2020-01-01T00:00:01Z",
+		});
+		builder2.sortId = "b0";
+		builder2.to = JID.parse("alice@example.com");
+		builder2.from = JID.parse("teaparty@example.com/hatter");
+		builder2.replyTo = [builder2.from.asBare()];
+
+		final builder3 = new ChatMessageBuilder({
+			serverId: "3",
+			serverIdBy: "alice@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannelPrivate,
+			timestamp: "2020-01-01T00:00:03Z",
+		});
+		builder3.sortId = "Z~";
+		builder3.to = JID.parse("alice@example.com");
+		builder3.from = JID.parse("teaparty@example.com/hatter");
+		builder3.replyTo = [builder3.from.asBare()];
+
+		final builder4 = new ChatMessageBuilder({
+			serverId: "4",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannel,
+			timestamp: "2020-01-01T00:00:04Z",
+		});
+		builder4.sortId = "c0";
+		builder4.to = JID.parse("alice@example.com");
+		builder4.from = JID.parse("teaparty@example.com/hatter");
+		builder4.replyTo = [builder4.from.asBare()];
+
+		persistence.storeMessages(account, [
+			builder2.build(),
+			builder4.build(),
+			builder3.build(),
+			builder.build(),
+		]).then(_ -> {
+			return persistence.getMessagesBefore(account, "teaparty@example.com", builder3.build());
+		}).then(result -> {
+			Assert.equals(2, result.length);
+			Assert.equals("1", result[0].serverId);
+			Assert.equals("2", result[1].serverId);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testMessagesAfter(async: Async) {
+		final account = "alice@example.com";
+		final builder = new ChatMessageBuilder({
+			serverId: "1",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannel,
+			timestamp: "2020-01-01T00:00:00Z",
+		});
+		builder.sortId = "a0";
+		builder.to = JID.parse("alice@example.com");
+		builder.from = JID.parse("teaparty@example.com/hatter");
+		builder.replyTo = [builder.from.asBare()];
+
+		final builder2 = new ChatMessageBuilder({
+			serverId: "2",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannel,
+			timestamp: "2020-01-01T00:00:01Z",
+		});
+		builder2.sortId = "b0";
+		builder2.to = JID.parse("alice@example.com");
+		builder2.from = JID.parse("teaparty@example.com/hatter");
+		builder2.replyTo = [builder2.from.asBare()];
+
+		final builder3 = new ChatMessageBuilder({
+			serverId: "3",
+			serverIdBy: "alice@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannelPrivate,
+			timestamp: "2020-01-01T00:00:03Z",
+		});
+		builder3.sortId = "a1";
+		builder3.to = JID.parse("alice@example.com");
+		builder3.from = JID.parse("teaparty@example.com/hatter");
+		builder3.replyTo = [builder3.from.asBare()];
+
+		persistence.storeMessages(account, [
+			builder2.build(),
+			builder3.build(),
+			builder.build(),
+		]).then(_ -> {
+			return persistence.getMessagesAfter(account, "teaparty@example.com", null);
+		}).then(result -> {
+			Assert.equals(3, result.length);
+			Assert.equals("1", result[0].serverId);
+			Assert.equals("2", result[1].serverId);
+			Assert.equals("3", result[2].serverId);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testMessagesAfterPoint(async: Async) {
+		final account = "alice@example.com";
+		final builder = new ChatMessageBuilder({
+			serverId: "1",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannel,
+			timestamp: "2020-01-01T00:00:01Z",
+		});
+		builder.sortId = "a0";
+		builder.to = JID.parse("alice@example.com");
+		builder.from = JID.parse("teaparty@example.com/hatter");
+		builder.replyTo = [builder.from.asBare()];
+
+		final builder2 = new ChatMessageBuilder({
+			serverId: "2",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannel,
+			timestamp: "2020-01-01T00:00:00Z",
+		});
+		builder2.sortId = "b0";
+		builder2.to = JID.parse("alice@example.com");
+		builder2.from = JID.parse("teaparty@example.com/hatter");
+		builder2.replyTo = [builder2.from.asBare()];
+
+		final builder3 = new ChatMessageBuilder({
+			serverId: "3",
+			serverIdBy: "alice@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannelPrivate,
+			timestamp: "2020-01-01T00:00:03Z",
+		});
+		builder3.sortId = "Z~";
+		builder3.to = JID.parse("alice@example.com");
+		builder3.from = JID.parse("teaparty@example.com/hatter");
+		builder3.replyTo = [builder3.from.asBare()];
+
+		final builder4 = new ChatMessageBuilder({
+			serverId: "4",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannel,
+			timestamp: "2020-01-01T00:00:04Z",
+		});
+		builder4.sortId = "c0";
+		builder4.to = JID.parse("alice@example.com");
+		builder4.from = JID.parse("teaparty@example.com/hatter");
+		builder4.replyTo = [builder4.from.asBare()];
+
+		persistence.storeMessages(account, [
+			builder2.build(),
+			builder4.build(),
+			builder3.build(),
+			builder.build(),
+		]).then(_ -> {
+			return persistence.getMessagesAfter(account, "teaparty@example.com", builder.build());
+		}).then(result -> {
+			Assert.equals(3, result.length);
+			Assert.equals("2", result[0].serverId);
+			Assert.equals("3", result[1].serverId);
+			Assert.equals("4", result[2].serverId);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testMessagesAfterPM(async: Async) {
+		final account = "alice@example.com";
+		final builder = new ChatMessageBuilder({
+			serverId: "1",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannel,
+			timestamp: "2020-01-01T00:00:00Z",
+		});
+		builder.sortId = "a0";
+		builder.to = JID.parse("alice@example.com");
+		builder.from = JID.parse("teaparty@example.com/hatter");
+		builder.replyTo = [builder.from.asBare()];
+
+		final builder2 = new ChatMessageBuilder({
+			serverId: "2",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannel,
+			timestamp: "2020-01-01T00:00:01Z",
+		});
+		builder2.sortId = "b0";
+		builder2.to = JID.parse("alice@example.com");
+		builder2.from = JID.parse("teaparty@example.com/hatter");
+		builder2.replyTo = [builder2.from.asBare()];
+
+		final builder3 = new ChatMessageBuilder({
+			serverId: "3",
+			serverIdBy: "alice@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannelPrivate,
+			timestamp: "2020-01-01T00:00:03Z",
+		});
+		builder3.sortId = "Z~";
+		builder3.to = JID.parse("alice@example.com");
+		builder3.from = JID.parse("teaparty@example.com/hatter");
+		builder3.replyTo = [builder3.from.asBare()];
+
+		final builder4 = new ChatMessageBuilder({
+			serverId: "4",
+			serverIdBy: "teaparty@example.com",
+			senderId: "teaparty@example.com/hatter",
+			direction: MessageReceived,
+			type: MessageChannel,
+			timestamp: "2020-01-01T00:00:04Z",
+		});
+		builder4.sortId = "c0";
+		builder4.to = JID.parse("alice@example.com");
+		builder4.from = JID.parse("teaparty@example.com/hatter");
+		builder4.replyTo = [builder4.from.asBare()];
+
+		persistence.storeMessages(account, [
+			builder2.build(),
+			builder4.build(),
+			builder3.build(),
+			builder.build(),
+		]).then(_ -> {
+			return persistence.getMessagesAfter(account, "teaparty@example.com", builder3.build());
+		}).then(result -> {
+			Assert.equals(1, result.length);
+			Assert.equals("4", result[0].serverId);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testStoreChats(async: Async) {
+		final account = "alice@example.com";
+		final chat = new DirectChat(cast null, cast null, persistence, "hatter@example.com");
+		chat.displayName = "The Mad Hatter";
+		chat.trusted = true;
+		chat.threads.set(null, "Tea Time");
+		chat.threads.set("thread-1", "Introductions");
+
+		persistence.storeChats(account, [chat]);
+		haxe.Timer.delay(() -> {
+			persistence.getChats(account).then(chats -> {
+				Assert.equals(1, chats.length);
+				Assert.equals("hatter@example.com", chats[0].chatId);
+				Assert.equals("The Mad Hatter", chats[0].displayName);
+				Assert.isTrue(chats[0].trusted);
+				Assert.equals("DirectChat", chats[0].klass);
+				Assert.equals("Tea Time", chats[0].threads.get(null));
+				Assert.equals("Introductions", chats[0].threads.get("thread-1"));
+				async.done();
+			}).catchError(e -> {
+				Assert.fail(Std.string(e));
+				async.done();
+			});
+		}, 200);
+	}
+
+	public function testGetMessage(async: Async) {
+		final account = "alice@example.com";
+		final builder = new ChatMessageBuilder({
+			serverId: "srv1",
+			serverIdBy: "hatter@example.com",
+			localId: "loc1",
+			senderId: "hatter@example.com",
+			direction: MessageReceived,
+		});
+		builder.sortId = "a0";
+		builder.to = JID.parse("alice@example.com");
+		builder.from = JID.parse("hatter@example.com");
+		builder.recipients = [builder.to];
+		builder.replyTo = [builder.from];
+
+		persistence.storeMessages(account, [builder.build()]).then(_ -> {
+			return persistence.getMessage(account, "hatter@example.com", "srv1", null);
+		}).then(byServerId -> {
+			Assert.notNull(byServerId);
+			Assert.equals("srv1", byServerId.serverId);
+			return persistence.getMessage(account, "hatter@example.com", null, "loc1");
+		}).then(byLocalId -> {
+			Assert.notNull(byLocalId);
+			Assert.equals("loc1", byLocalId.localId);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testStoreReaction(async: Async) {
+		final account = "alice@example.com";
+		final builder = new ChatMessageBuilder({
+			serverId: "srv1",
+			serverIdBy: "hatter@example.com",
+			senderId: "hatter@example.com",
+			direction: MessageReceived,
+		});
+		builder.sortId = "a0";
+		builder.to = JID.parse("alice@example.com");
+		builder.from = JID.parse("hatter@example.com");
+		builder.recipients = [builder.to];
+		builder.replyTo = [builder.from];
+
+		persistence.storeMessages(account, [builder.build()]).then(_ -> {
+			final reaction = new Reaction("alice@example.com", "2020-01-01T00:00:01Z", "👍");
+			final update = new ReactionUpdate(
+				"up1",
+				"srv1",
+				"hatter@example.com",
+				null,
+				"hatter@example.com",
+				"alice@example.com",
+				"2020-01-01T00:00:01Z",
+				[reaction],
+				EmojiReactions
+			);
+			return persistence.storeReaction(account, update);
+		}).then(msg -> {
+			Assert.notNull(msg);
+			final reactions = msg.reactions;
+			Assert.equals(1, Lambda.count({ iterator: () -> reactions.iterator() }));
+			Assert.isTrue(reactions.exists("👍"));
+			Assert.equals(1, reactions.get("👍").length);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testUpdateMessageStatus(async: Async) {
+		final account = "alice@example.com";
+		final builder = new ChatMessageBuilder({
+			localId: "loc1",
+			senderId: "alice@example.com",
+			direction: MessageSent,
+		});
+		builder.sortId = "a0";
+		builder.to = JID.parse("hatter@example.com");
+		builder.from = JID.parse("alice@example.com");
+		builder.recipients = [builder.to];
+		builder.replyTo = [builder.from];
+
+		persistence.storeMessages(account, [builder.build()]).then(_ -> {
+			return persistence.updateMessageStatus(account, "loc1", MessageDeliveredToServer, "Delivered");
+		}).then(updated -> {
+			Assert.equals(MessageDeliveredToServer, updated.status);
+			Assert.equals("Delivered", updated.statusText);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testSearchMessages(async: Async) {
+		final account = "alice@example.com";
+		final builder = new ChatMessageBuilder({
+			serverId: "srv1",
+			serverIdBy: "hatter@example.com",
+			senderId: "hatter@example.com",
+			direction: MessageReceived,
+		});
+		builder.sortId = "a0";
+		builder.setBody(Html.text("Hello world"));
+		builder.to = JID.parse("alice@example.com");
+		builder.from = JID.parse("hatter@example.com");
+		builder.recipients = [builder.to];
+		builder.replyTo = [builder.from];
+
+		final builder2 = new ChatMessageBuilder({
+			serverId: "srv2",
+			serverIdBy: "hatter@example.com",
+			senderId: "hatter@example.com",
+			direction: MessageReceived,
+		});
+		builder2.sortId = "a1";
+		builder2.setBody(Html.text("Goodbye world"));
+		builder2.to = JID.parse("alice@example.com");
+		builder2.from = JID.parse("hatter@example.com");
+		builder2.recipients = [builder2.to];
+		builder2.replyTo = [builder2.from];
+
+		persistence.storeMessages(account, [builder.build(), builder2.build()]).then(_ -> {
+			return persistence.searchMessages(account, "hatter@example.com", "hello");
+		}).then(results -> {
+			Assert.equals(1, results.length);
+			Assert.equals("Hello world", results[0].text);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testRemoveAccount(async: Async) {
+		final account1 = "alice@example.com";
+		final account2 = "bob@example.com";
+
+		persistence.storeLogin(account1, "client1", "Alice", null);
+		persistence.storeLogin(account2, "client2", "Bob", null);
+
+		persistence.listAccounts().then(accountsBefore -> {
+			Assert.contains(account1, accountsBefore);
+			Assert.contains(account2, accountsBefore);
+			persistence.removeAccount(account1, true);
+			return persistence.listAccounts();
+		}).then(accountsAfter -> {
+			Assert.notContains(account1, accountsAfter);
+			Assert.contains(account2, accountsAfter);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testGetChatUnreadDetails(async: Async) {
+		final account = "alice@example.com";
+		final chat = new DirectChat(cast null, cast null, persistence, "hatter@example.com");
+		chat.readUpToId = "srv1";
+
+		final builder = new ChatMessageBuilder({
+			serverId: "srv1",
+			serverIdBy: "hatter@example.com",
+			senderId: "hatter@example.com",
+			direction: MessageReceived,
+		});
+		builder.sortId = "a0";
+		builder.to = JID.parse("alice@example.com");
+		builder.from = JID.parse("hatter@example.com");
+		builder.recipients = [builder.to];
+		builder.replyTo = [builder.from];
+
+		final builder2 = new ChatMessageBuilder({
+			serverId: "srv2",
+			serverIdBy: "hatter@example.com",
+			senderId: "hatter@example.com",
+			direction: MessageReceived,
+		});
+		builder2.sortId = "a1";
+		builder2.to = JID.parse("alice@example.com");
+		builder2.from = JID.parse("hatter@example.com");
+		builder2.recipients = [builder2.to];
+		builder2.replyTo = [builder2.from];
+
+		persistence.storeMessages(account, [builder.build(), builder2.build()]).then(_ -> {
+			return persistence.getChatUnreadDetails(account, chat);
+		}).then(result -> {
+			Assert.equals(1, result.unreadCount);
+			Assert.equals("srv2", result.message.serverId);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testMedia(async: Async) {
+		final bytes = haxe.io.Bytes.ofString("hello").getData();
+		persistence.storeMedia("image/png", bytes).then(_ -> {
+			return persistence.hasMedia("sha-256", Hash.sha256(haxe.io.Bytes.ofData(bytes)).hash);
+		}).then(hasBefore -> {
+			Assert.isTrue(hasBefore);
+			persistence.removeMedia("sha-256", Hash.sha256(haxe.io.Bytes.ofData(bytes)).hash);
+			return persistence.hasMedia("sha-256", Hash.sha256(haxe.io.Bytes.ofData(bytes)).hash);
+		}).then(hasAfter -> {
+			Assert.isFalse(hasAfter);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testHydrateReplyTo(async: Async) {
+		final account = "alice@example.com";
+		final builder = new ChatMessageBuilder({
+			serverId: "parent",
+			serverIdBy: "hatter@example.com",
+			localId: "loc1",
+			senderId: "hatter@example.com",
+			direction: MessageReceived,
+		});
+		builder.sortId = "a0";
+		builder.to = JID.parse("alice@example.com");
+		builder.from = JID.parse("hatter@example.com");
+		builder.recipients = [builder.to];
+		builder.replyTo = [builder.from];
+		final parentStub = builder.build();
+
+		builder.setBody(Html.text("Hello"));
+		final parentMsg = builder.build();
+
+		final builder2 = new ChatMessageBuilder({
+			serverId: "child",
+			serverIdBy: "hatter@example.com",
+			localId: "loc2",
+			senderId: "hatter@example.com",
+			direction: MessageReceived,
+		});
+		builder2.sortId = "a1";
+		builder2.to = JID.parse("alice@example.com");
+		builder2.from = JID.parse("hatter@example.com");
+		builder2.recipients = [builder2.to];
+		builder2.replyTo = [builder2.from];
+		builder2.replyToMessage = parentStub;
+		final childMsg = builder2.build();
+
+		persistence.storeMessages(account, [parentMsg]).then(_ -> {
+			return persistence.storeMessages(account, [childMsg]);
+		}).then(msgs -> {
+			final childStored = msgs[0];
+			Assert.notNull(childStored.replyToMessage);
+			Assert.equals("Hello", childStored.replyToMessage.text);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testStoreChatsWithStatus(async: Async) {
+		final account = "alice@example.com";
+		final chat = new DirectChat(cast null, cast null, persistence, "hatter@example.com");
+		chat.displayName = "The Mad Hatter";
+		chat.trusted = true;
+		chat.status = new Status("🎩", "Time for tea!");
+
+		persistence.storeChats(account, [chat]);
+		haxe.Timer.delay(() -> {
+			persistence.getChats(account).then(chats -> {
+				Assert.equals(1, chats.length);
+				Assert.equals("hatter@example.com", chats[0].chatId);
+				Assert.equals("🎩", chats[0].status.emoji);
+				Assert.equals("Time for tea!", chats[0].status.text);
+				async.done();
+			}).catchError(e -> {
+				Assert.fail(Std.string(e));
+				async.done();
+			});
+		}, 200);
+	}
+}
diff --git a/testjs.hxml b/testjs.hxml
new file mode 100644
index 0000000..c7db676
--- /dev/null
+++ b/testjs.hxml
@@ -0,0 +1,26 @@
+--library HtmlParser
+--library datetime
+--library fractional-indexing
+--library haxe-strings
+--library hsluv
+--library hxnodejs
+--library jsImport
+--library thenshim
+--library tink_http
+--library uuidv7
+
+--library utest
+
+--macro borogove.Util.DummyRequireMacro.init()
+-D test
+-D analyzer-optimize
+-D js-es=6
+-D js_global=globalThis
+-D NO_OMEMO
+-D no-traces
+-main test.TestAll
+-w -WDeprecated
+--js .cache/test.js
+
+--cmd npx cjstoesm -s true .cache/test.js
+--cmd nodejs --disable-warning=ExperimentalWarning .cache/test.js