git » sdk » commit fa01eaf

Implement sqlite persistence for members lists

author Stephen Paul Weber
2026-05-25 02:54:31 UTC
committer Stephen Paul Weber
2026-05-25 02:56:00 UTC
parent a7e3c2afa844d972090f6b706f7c59bf70751b24

Implement sqlite persistence for members lists

Makefile +1 -1
borogove/CapsRepo.hx +1 -0
borogove/Member.hx +1 -1
borogove/persistence/IDB.js +1 -1
borogove/persistence/Sqlite.hx +302 -49
test/TestSqlite.hx +433 -0
test/sqlite.spec.ts +1026 -3

diff --git a/Makefile b/Makefile
index 4406508..3fdbd34 100644
--- a/Makefile
+++ b/Makefile
@@ -65,7 +65,7 @@ playwright/.cache/borogove.js: npm
 	cd npm && esbuild index.js --bundle --format=esm "--alias:node:dns=@xmpp/resolve" "--footer:js=export { borogove_JID as JID, borogove_Stanza as Stanza, borogove_ReactionUpdate as ReactionUpdate, borogove_MemberUpdate as MemberUpdate }" --outfile=../$@
 
 playwright/.cache/sqlite-wasm.js: npm
-	cd npm && esbuild sqlite-wasm.js --bundle --format=esm "--alias:node:dns=@xmpp/resolve" --outfile=../$@
+	cd npm && esbuild sqlite-wasm.js --bundle --format=esm "--alias:node:dns=@xmpp/resolve" "--footer:js=export { borogove_Channel as Channel }"  --outfile=../$@
 	sed -i 's/new URL("sqlite-worker1.mjs", import.meta.url)/window.sqliteWorker1Url/g' $@
 
 playwright/.cache/sqlite-worker1.js: npm
diff --git a/borogove/CapsRepo.hx b/borogove/CapsRepo.hx
index cf44532..99ecb39 100644
--- a/borogove/CapsRepo.hx
+++ b/borogove/CapsRepo.hx
@@ -3,6 +3,7 @@ package borogove;
 import thenshim.Promise;
 
 @:nullSafety(StrictThreaded)
+@:expose
 class CapsRepo {
 	public static final empty = new Caps("", [], [], []);
 	private final persistence: Persistence;
diff --git a/borogove/Member.hx b/borogove/Member.hx
index dff9e17..09ab07a 100644
--- a/borogove/Member.hx
+++ b/borogove/Member.hx
@@ -58,7 +58,7 @@ class Member {
 	@:allow(borogove)
 	private var presence: Map<String, Presence>;
 
-	@:allow(borogove.MemberUpdate)
+	@:allow(borogove)
 	private final jid: JID;
 
 	@:allow(borogove)
diff --git a/borogove/persistence/IDB.js b/borogove/persistence/IDB.js
index 95cc2b5..4101744 100644
--- a/borogove/persistence/IDB.js
+++ b/borogove/persistence/IDB.js
@@ -621,7 +621,7 @@ export default async (dbname, media, tokenize, stemmer) => {
 				}
 			}
 
-			return pseudoMembers.filter(m => m?.id && m?.displayName && m?.jid).map(m => hydrateMember(chat, {...m, chat: chat?.chatId }));
+			return pseudoMembers.filter(m => m?.id && m?.displayName && m?.jid).map(m => hydrateMember(chat, {...m, chat: m.chat?.chatId }));
 		},
 
 		async clearMemberPresence(account, chatId) {
diff --git a/borogove/persistence/Sqlite.hx b/borogove/persistence/Sqlite.hx
index 909ad82..1ab99c2 100644
--- a/borogove/persistence/Sqlite.hx
+++ b/borogove/persistence/Sqlite.hx
@@ -11,9 +11,14 @@ import haxe.io.BytesData;
 import thenshim.Promise;
 import borogove.Caps;
 import borogove.Chat;
+import borogove.Chat.AvailableChat;
 import borogove.Message;
+import borogove.Member;
+import borogove.MemberUpdate;
+import borogove.Presence;
 import borogove.Reaction;
 import borogove.ReactionUpdate;
+import borogove.Role;
 #if !NO_OMEMO
 import borogove.OMEMO;
 using borogove.SignalProtocol;
@@ -231,6 +236,26 @@ class Sqlite implements Persistence implements KeyValueStore {
 						"PRAGMA user_version = 10"]);
 					}
 					return Promise.resolve(null);
+				}).then(_ -> {
+					if (version < 11) {
+						return exec(["CREATE TABLE members (
+							account_id TEXT NOT NULL,
+							chat_id TEXT NOT NULL,
+							member_id TEXT NOT NULL,
+							display_name TEXT,
+							photo_uri TEXT,
+							is_self INTEGER NOT NULL,
+							chat TEXT,
+							roles BLOB NOT NULL,
+							presence BLOB NOT NULL,
+							jid TEXT,
+							PRIMARY KEY (account_id, member_id)
+						) STRICT",
+						"CREATE INDEX members_chats ON members (account_id, chat_id, is_self, jid)",
+						"ALTER TABLE chats DROP COLUMN presence",
+						"PRAGMA user_version = 11"]);
+					}
+					return Promise.resolve(null);
 				});
 			});
 		});
@@ -284,20 +309,13 @@ class Sqlite implements Persistence implements KeyValueStore {
 		}
 
 		storeChatTimer = haxe.Timer.delay(() -> {
-			final mapPresence = (chat: Chat) -> {
-				final storePresence: DynamicAccess<String> = {};
-				/* TODO for (resource => presence in chat.presence) {
-					if (storePresence[resource ?? ""] == null) storePresence[resource ?? ""] = presence.toString();
-				}*/
-				return storePresence;
-			};
 			final q = new StringBuf();
 			q.add("INSERT OR REPLACE INTO chats VALUES ");
 			var first = true;
 			for (_ in storeChatBuffer) {
 				if (!first) q.add(",");
 				first = false;
-				q.add("(?,?,?,?,?,?,?,?,?,?,?,jsonb(?),?,?,?,?,?,jsonb(?))");
+				q.add("(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,jsonb(?))");
 			}
 			db.exec(
 				q.toString(),
@@ -308,11 +326,12 @@ class Sqlite implements Persistence implements KeyValueStore {
 						accountId, chat.chatId, chat.isTrusted(), chat.avatarSha1,
 						chat.getDisplayName(), chat.uiState, chat.isBlocked,
 						chat.extensions.toString(), chat.readUpToId, chat.readUpToBy,
-						channel?.disco?.verRaw().hash, Json.stringify(mapPresence(chat)),
+						channel?.disco?.verRaw().hash,
 						Type.getClassName(Type.getClass(chat)).split(".").pop(),
 						chat.notificationsFiltered(), chat.notifyMention(), chat.notifyReply(),
 						chat.isBookmarked, JsonPrinter.print({
 							status: { emoji: chat.status.emoji, text: chat.status.text },
+							mavUntil: channel?.mavUntil,
 							threads: {
 								final t: DynamicAccess<String> = {};
 								for (id => s in chat.threads) t.set(id ?? "", s);
@@ -328,34 +347,280 @@ class Sqlite implements Persistence implements KeyValueStore {
 		}, 100);
 	}
 
+	private function serializePresenceMap(presence: Map<String, Presence>) {
+		final storePresence: DynamicAccess<String> = {};
+		if (presence == null) return JsonPrinter.print(storePresence);
+
+		for (resource => p in presence) {
+			storePresence[resource ?? ""] = p.toString();
+		}
+
+		return JsonPrinter.print(storePresence);
+	}
+
+	private function hydratePresenceMap(raw: String): Map<String, Presence> {
+		final map: Map<String, Presence> = new Map();
+		if (raw == null || raw == "") return map;
+
+		final parsed: DynamicAccess<String> = Json.parse(raw);
+		for (resource => presence in parsed) {
+			map[resource == "" ? null : resource] = Stanza.parse(presence);
+		}
+
+		return map;
+	}
+
+	private function hydrateStoredMember(chat: Null<Chat>, row: Dynamic): Null<Member> {
+		if (row == null || row.member_id == null || row.member_id == "" || row.display_name == null || row.display_name == "" || row.jid == null || row.jid == "") {
+			return null;
+		}
+
+		final rolesData: Array<Dynamic> = row.roles == null ? [] : Json.parse(row.roles);
+		final roles = rolesData.map(r -> new Role(r.id, r.title));
+		final presence = hydratePresenceMap(row.presence);
+		final chatId: String = row.chat;
+		final availableChat = chatId == null || chatId == "" ? null : new AvailableChat(
+			chatId,
+			row.display_name,
+			chat == null ? chatId : chatId + " (via " + chat.getDisplayName() + ")",
+			CapsRepo.empty
+		);
+
+		return new Member(
+			row.member_id,
+			row.display_name,
+			row.photo_uri,
+			row.is_self != 0,
+			roles,
+			row.jid == null ? null : JID.parse(row.jid),
+			presence,
+			availableChat
+		);
+	}
+
+	private function chatPresenceAndMembersForName(accountId: String, rawChats: Array<Dynamic>): Promise<Map<String, { presence: Map<String, Presence>, membersForName: Null<Array<{id: String, displayName: String}>> }>> {
+		if (rawChats.length < 1) return Promise.resolve(new Map());
+
+		final query = "
+			SELECT chat_id, member_id, display_name, is_self, json(roles) AS roles, json(presence) AS presence
+			FROM (
+				SELECT chat_id, member_id, display_name, is_self, roles, presence,
+					CASE WHEN is_self OR member_id=chat_id THEN 0
+						ELSE ROW_NUMBER() OVER(PARTITION BY chat_id ORDER BY display_name)
+					END as rn
+				FROM members
+				WHERE account_id = ?
+					AND (is_self OR (display_name IS NOT NULL AND display_name<>''))
+			) sub
+			WHERE rn <= 23
+			ORDER BY chat_id, display_name
+		";
+
+		return db.exec(query, [accountId]).then(rows -> {
+			final result = new Map<String, { presence: Map<String, Presence>, membersForName: Null<Array<{id: String, displayName: String}>> }>();
+
+			final chatRows = new Map<String, Array<Dynamic>>();
+			for (row in rows) {
+				if (!chatRows.exists(row.chat_id)) chatRows[row.chat_id] = [];
+				chatRows[row.chat_id].push(row);
+			}
+
+			for (rawChat in rawChats) {
+				final cid = rawChat.chat_id;
+				final members = chatRows[cid];
+				if (members == null) {
+					result[cid] = { presence: new Map(), membersForName: null };
+					continue;
+				}
+
+				var presence: Map<String, Presence> = new Map();
+				var membersForName: Null<Array<{id: String, displayName: String}>> = null;
+
+				if (Reflect.field(rawChat, "class") == "DirectChat") {
+					final selfRow: Dynamic = members.find(r -> r.member_id == cid);
+					if (selfRow != null) presence = hydratePresenceMap(selfRow.presence);
+				} else {
+					membersForName = [];
+
+					for (row in members) {
+						if (row.is_self != 0) {
+							presence = hydratePresenceMap(row.presence);
+							continue;
+						}
+
+						if (row.member_id == cid) continue;
+
+						final rolesData: Array<Dynamic> = row.roles != null ? Json.parse(row.roles) : [];
+						if (rolesData.exists(r -> r.id == "none" || r.id == "outcast")) continue;
+
+						membersForName.push({ id: row.member_id, displayName: row.display_name });
+					}
+
+					if (membersForName.length > 20) {
+						membersForName = null;
+					} else {
+						membersForName.sort((a, b) -> Reflect.compare(a.displayName, b.displayName));
+					}
+				}
+
+				result[cid] = { presence: presence, membersForName: membersForName };
+			}
+
+			return result;
+		});
+	}
+
 	@HaxeCBridge.noemit
-	public function storeMembers(accountId: String, chatId: String, chat: Array<Member>) {
-		// TODO
-		return Promise.resolve(false);
+	public function storeMembers(accountId: String, chatId: String, membersArg: Array<Member>) {
+		if (membersArg.length < 1) return Promise.resolve(true);
+
+		// To allow being called with member-compatible Dynamics
+		final members: Array<Dynamic> = cast membersArg;
+
+		final q = new StringBuf();
+		q.add("INSERT OR REPLACE INTO members VALUES ");
+		var first = true;
+		final params: Array<Dynamic> = [];
+		final f: Dynamic = false;
+		for (member in members) {
+			if (!first) q.add(",");
+			first = false;
+			q.add("(?,?,?,?,?,?,?,jsonb(?),jsonb(?),?)");
+			params.push(accountId);
+			params.push(chatId);
+			params.push(member.id);
+			params.push(member.displayName);
+			params.push(member.photoUri);
+			params.push(member.isSelf);
+			params.push(member.chat?.chatId);
+			params.push(JsonPrinter.print(member.roles));
+			params.push(serializePresenceMap(member.presence));
+			params.push(member.jid?.asString());
+		}
+
+		return db.exec(q.toString(), params).then(_ -> true);
 	}
 
 	@HaxeCBridge.noemit
 	public function storeMemberUpdates(accountId: String, chat: Chat, updates: Array<MemberUpdate>, isFullList: Bool) {
-		// TODO
-		return Promise.resolve([]);
+		final updatedIds: Map<String, Bool> = [];
+		return thenshim.PromiseTools.all(updates.map(update -> {
+			return (update.id == null ? Promise.resolve(cast null) : db.exec(
+				"SELECT member_id, display_name, photo_uri, is_self, chat, json(roles) AS roles, json(presence) AS presence, jid FROM members WHERE account_id=? AND member_id=? LIMIT 1",
+				[accountId, update.id]
+			).then(rows ->
+				return rows.hasNext() ? rows.next() : null
+			)).then(existing -> {
+				if (existing != null || update.jid == null) return Promise.resolve(existing);
+
+				return db.exec(
+					"SELECT member_id, display_name, photo_uri, is_self, chat, json(roles) AS roles, json(presence) AS presence, jid FROM members WHERE account_id=? AND chat_id=? AND jid=? LIMIT 1",
+					[accountId, chat.chatId, update.jid.asString()]
+				).then(rows ->
+					return rows.hasNext() ? rows.next() : null
+				);
+			}).then(existing -> {
+				final member = hydrateStoredMember(chat, existing);
+				final knownId = update.id ?? existing?.member_id;
+				if (knownId != null && knownId != "") updatedIds[knownId] = true;
+
+				return update.applyTo(member);
+			});
+		})).then(pseudoMembers -> {
+			final valid = pseudoMembers.filter(m -> m != null);
+			return storeMembers(accountId, chat.chatId, cast valid).then(_ -> valid);
+		}).then(valid -> {
+			if (!isFullList) return Promise.resolve(valid);
+
+			final ids = [for (id => _ in updatedIds) id];
+			return if (ids.length < 1) {
+				db.exec(
+					"UPDATE members SET roles=jsonb('[]') WHERE account_id=? AND chat_id=?",
+					[accountId, chat.chatId]
+				).then(_ -> valid);
+			} else {
+				final placeholders = ids.map(_ -> "?").join(",");
+				final params: Array<Dynamic> = [accountId, chat.chatId].concat(ids);
+				db.exec(
+					"UPDATE members SET roles=jsonb('[]') WHERE account_id=? AND chat_id=? AND member_id NOT IN (" + placeholders + ")",
+					params
+				).then(_ -> valid);
+			}
+		}).then(valid -> {
+			valid.filter(m -> m?.id != null && m?.displayName != null && m.displayName != "" && m?.jid != null).map(m -> {
+				return new Member(
+					m.id,
+					m.displayName,
+					m.photoUri,
+					m.isSelf,
+					cast m.roles,
+					m.jid,
+					m.presence,
+					m.chat
+				);
+			});
+		});
 	}
 
 	@HaxeCBridge.noemit
 	public function clearMemberPresence(accountId: String, chatId: Null<String>) {
-		//  TODO
-		return Promise.resolve(false);
+		return db.exec(
+			chatId == null
+				? "UPDATE members SET presence=jsonb('{}') WHERE account_id=?"
+				: "UPDATE members SET presence=jsonb('{}') WHERE account_id=? AND chat_id=?",
+			chatId == null ? [accountId] : [accountId, chatId]
+		).then(_ -> true);
 	}
 
 	@HaxeCBridge.noemit
 	public function getMembers(accountId: String, chat: Chat, forModerator: Bool) {
-		//  TODO
-		return Promise.resolve([]);
+		return db.exec(
+			"SELECT
+				member_id, display_name, photo_uri, is_self, chat,
+				json(roles) AS roles, json(presence) AS presence, jid,
+				COALESCE(
+					(SELECT MAX(CASE value->>'$.id' WHEN 'owner' THEN 4 WHEN 'admin' THEN 3 WHEN 'none' THEN 1 WHEN 'outcast' THEN 0 ELSE 2 END) FROM json_each(roles)),
+					2
+				) AS role_rank,
+				CASE WHEN json(presence) NOT LIKE '% type=\\\"unavailable\\\"%' THEN 1 ELSE 0 END AS is_online
+			FROM members
+			WHERE
+				account_id=? AND chat_id=?
+				AND (? OR NOT EXISTS (SELECT 1 FROM json_each(roles) WHERE value->>'$.id' = 'outcast'))
+				AND NOT (EXISTS (SELECT 1 FROM json_each(roles) WHERE value->>'$.id' = 'none') AND NOT is_online)
+			ORDER BY role_rank DESC, is_online DESC, LOWER(display_name) ASC
+			LIMIT " + (forModerator ? 20000 : 2000),
+			[accountId, chat.chatId, forModerator]
+		).then(rows -> {
+			final result: Array<Member> = [];
+			for (row in rows) {
+				final member = hydrateStoredMember(chat, row);
+				if (member == null) continue;
+
+				result.push(member);
+			}
+			if (result.length > 1000 && !forModerator) return result.filter(m -> m.showPresence != Offline);
+			return result;
+		});
 	}
 
 	@HaxeCBridge.noemit
 	public function getMemberDetails(accountId: String, chat: Null<Chat>, ids: Array<String>) {
-		// TODO
-		return Promise.resolve([]);
+		if (ids.length == 0) return thenshim.Promise.resolve([]);
+
+		var placeholders = ids.map(_ -> "?").join(", ");
+		var sql = 'SELECT member_id, display_name, photo_uri, is_self, chat, json(roles) AS roles, json(presence) AS presence, jid FROM members WHERE account_id = ? AND member_id IN ($placeholders)';
+
+		var params = [accountId].concat(ids);
+
+		return db.exec(sql, params).then(rows -> {
+			var lookup = new Map<String, Member>();
+			for (row in rows) {
+				lookup.set(row.member_id, hydrateStoredMember(chat, row));
+			}
+
+			return ids.map(id -> lookup[id]);
+		});
 	}
 
 	@HaxeCBridge.noemit
@@ -372,40 +637,28 @@ class Sqlite implements Persistence implements KeyValueStore {
 	@HaxeCBridge.noemit
 	public function getChats(accountId: String): Promise<Array<SerializedChat>> {
 		return db.exec(
-			"SELECT chat_id, trusted, bookmarked, avatar_sha1, fn, ui_state, blocked, extensions, read_up_to_id, read_up_to_by, notifications_filtered, notify_mention, notify_reply, json(caps) AS caps, caps_ver, json(presence) AS presence, json(meta) AS meta, class FROM chats LEFT JOIN caps ON chats.caps_ver=caps.sha1 WHERE account_id=?",
+			"SELECT chat_id, trusted, bookmarked, avatar_sha1, fn, ui_state, blocked, extensions, read_up_to_id, read_up_to_by, notifications_filtered, notify_mention, notify_reply, json(caps) AS caps, caps_ver, json(meta) AS meta, class FROM chats LEFT JOIN caps ON chats.caps_ver=caps.sha1 WHERE account_id=?",
 			[accountId]
 		).then(result -> {
-			final chats: Array<SerializedChat> = [];
-			for (row in result) {
-				final capsJson = row.caps == null ? null : Json.parse(row.caps);
-				row.capsObj = capsJson == null ? null : hydrateCaps(capsJson, row.caps_ver);
-				final presenceJson: DynamicAccess<Dynamic> = Json.parse(row.presence);
-				final presenceMap: Map<String, Presence> = [];
-				for (resource => presence in presenceJson) {
-					if (Std.isOfType(presence, String)) {
-						presenceMap[resource] = Stanza.parse(presence);
-					} else {
-						presenceMap[resource] = new Presence(
-							presence.caps == null ? null : new Caps("", [], [], [], Base64.decode(presence.caps).getData()),
-							presence.mucUser == null ? null : Stanza.parse(presence.mucUser),
-							presence.avatarHash == null ? null : Hash.fromUri(presence.avatarHash)
-						);
+			final rows = { iterator: () -> result }.array();
+			return chatPresenceAndMembersForName(accountId, rows).then(chatData -> {
+				final chats: Array<SerializedChat> = [];
+				for (row in rows) {
+					final capsJson = row.caps == null ? null : Json.parse(row.caps);
+					row.capsObj = capsJson == null ? null : hydrateCaps(capsJson, row.caps_ver);
+
+					final metaJson: { ?threads: Null<DynamicAccess<String>>, ?status: Null<{ emoji: String, text: String }>, ?mavUntil: Null<String> } = Json.parse(row.meta);
+					final threadsMap: StringMapNullableKey<String> = new StringMapNullableKey();
+					for (thread => subject in metaJson.threads ?? {}) {
+						threadsMap.set(thread == "" ? null : thread, subject);
 					}
-				}
 
-				final metaJson: { ?threads: Null<DynamicAccess<String>>, ?status: Null<{ emoji: String, text: String }> } = Json.parse(row.meta);
-				final threadsMap: StringMapNullableKey<String> = new StringMapNullableKey();
-				for (thread => subject in metaJson.threads ?? {}) {
-					threadsMap.set(thread == "" ? null : thread, subject);
+					// FIXME: Empty OMEMO contact device ids hardcoded in next line
+					final derived = chatData[row.chat_id];
+					chats.push(new SerializedChat(row.chat_id, row.trusted != 0, row.bookmarked != 0, row.avatar_sha1, derived.presence ?? new Map(), derived?.membersForName, row.fn, row.ui_state, row.blocked != 0, new Status(metaJson.status?.emoji ?? "", metaJson.status?.text ?? ""), row.extensions, row.read_up_to_id, row.read_up_to_by, row.notifications_filtered == null ? null : row.notifications_filtered != 0, row.notify_mention != 0, row.notify_reply != 0, threadsMap, row.capsObj, metaJson.mavUntil, [], Reflect.field(row, "class")));
 				}
-
-				// FIXME: Empty OMEMO contact device ids hardcoded in next line
-				// TODO: memebersForName
-				// TODO: new presence storage
-				// TODO: mavUntil
-				chats.push(new SerializedChat(row.chat_id, row.trusted != 0, row.bookmarked != 0, row.avatar_sha1, presenceMap, [], row.fn, row.ui_state, row.blocked != 0, new Status(metaJson.status?.emoji ?? "", metaJson.status?.text ?? ""), row.extensions, row.read_up_to_id, row.read_up_to_by, row.notifications_filtered == null ? null : row.notifications_filtered != 0, row.notify_mention != 0, row.notify_reply != 0, threadsMap, row.capsObj, null, [], Reflect.field(row, "class")));
-			}
-			return chats;
+				return chats;
+			});
 		});
 	}
 
diff --git a/test/TestSqlite.hx b/test/TestSqlite.hx
index d7be968..00bae9a 100644
--- a/test/TestSqlite.hx
+++ b/test/TestSqlite.hx
@@ -15,11 +15,16 @@ import borogove.JID;
 import borogove.ID;
 import borogove.Message;
 import borogove.Chat;
+import borogove.Chat.AvailableChat;
 import borogove.Status;
 import borogove.Reaction;
 import borogove.ReactionUpdate;
 import borogove.Html;
 import borogove.Hash;
+import borogove.Member;
+import borogove.MemberUpdate;
+import borogove.Role;
+import borogove.Stanza;
 
 using Lambda;
 using thenshim.PromiseTools;
@@ -693,6 +698,7 @@ class TestSqlite extends utest.Test {
 	public function testGetChatUnreadDetails(async: Async) {
 		final account = "alice@example.com";
 		final chat = new DirectChat(cast null, cast null, persistence, "hatter@example.com");
+		chat.displayName = "A Chat";
 		chat.readUpToId = "srv1";
 
 		final builder = new ChatMessageBuilder();
@@ -814,6 +820,127 @@ class TestSqlite extends utest.Test {
 		}, 200);
 	}
 
+	public function testGetChatsUsesMemberPresenceForDirectChats(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.setPresence("desk", Stanza.parse("<presence />"), true);
+
+		persistence.storeChats(account, [chat]);
+		persistence.storeMembers(account, chat.chatId, [
+			new Member(
+				"hatter@example.com",
+				"The Mad Hatter",
+				null,
+				false,
+				[],
+				JID.parse("hatter@example.com"),
+				["phone" => Stanza.parse("<presence />")],
+				null
+			)
+		]).then(_ -> {
+			haxe.Timer.delay(() -> {
+				persistence.getChats(account).then(chats -> {
+					final stored = chats[0];
+					Assert.equals(1, [for (_ in stored.presence.keys()) _].length);
+					Assert.notNull(stored.presence["phone"]);
+					Assert.isNull(stored.presence["desk"]);
+					async.done();
+				}).catchError(e -> {
+					Assert.fail(Std.string(e));
+					async.done();
+				});
+			}, 200);
+			return null;
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testGetChatsHydratesMembersForNameAndMavUntil(async: Async) {
+		final account = "alice@example.com";
+		final chat = new Channel(cast null, cast null, persistence, "room-chat-hydrate@example.com");
+		chat.displayName = "Tea Room";
+		chat.mavUntil = "2024-05-01T12:00:00Z";
+
+		persistence.storeChats(account, [chat]);
+		persistence.storeMembers(account, chat.chatId, [
+			new Member(
+				chat.chatId,
+				"Tea Room",
+				null,
+				false,
+				[],
+				JID.parse(chat.chatId),
+				new Map(),
+				null
+			),
+			new Member(
+				chat.chatId + "/self",
+				"Myself",
+				null,
+				true,
+				[new Role("owner", "Owner")],
+				JID.parse("alice@example.com"),
+				["desk" => Stanza.parse("<presence />")],
+				null
+			),
+			new Member(
+				chat.chatId + "/zulu",
+				"Zulu",
+				null,
+				false,
+				[new Role("admin", "Admin")],
+				JID.parse("zulu@example.com"),
+				["desk" => Stanza.parse("<presence />")],
+				new AvailableChat("zulu@example.com", "Zulu", "", new borogove.Caps("", [], [], []))
+			),
+			new Member(
+				chat.chatId + "/alpha",
+				"Alpha",
+				null,
+				false,
+				[],
+				JID.parse("alpha@example.com"),
+				["desk" => Stanza.parse("<presence />")],
+				new AvailableChat("alpha@example.com", "Alpha", "", new borogove.Caps("", [], [], []))
+			),
+			new Member(
+				chat.chatId + "/hidden",
+				"Hidden",
+				null,
+				false,
+				[new Role("none", "Guest")],
+				JID.parse("hidden@example.com"),
+				["desk" => Stanza.parse("<presence />")],
+				new AvailableChat("hidden@example.com", "Hidden", "", new borogove.Caps("", [], [], []))
+			)
+		]).then(_ -> {
+			haxe.Timer.delay(() -> {
+				persistence.getChats(account).then(chats -> {
+					final stored = chats[0];
+					Assert.notNull(stored.membersForName);
+					Assert.equals("2024-05-01T12:00:00Z", stored.mavUntil);
+					Assert.equals(2, stored.membersForName.length);
+					Assert.equals("Alpha", stored.membersForName[0].displayName);
+					Assert.equals("Zulu", stored.membersForName[1].displayName);
+					Assert.equals(1, [for (_ in stored.presence.keys()) _].length);
+					Assert.notNull(stored.presence["desk"]);
+					async.done();
+				}).catchError(e -> {
+					Assert.fail(Std.string(e));
+					async.done();
+				});
+			}, 200);
+			return null;
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
 	public function testStoreStreamManamagementAndGetStreamManagement(async: Async) {
 		persistence.storeLogin("alice@example.com", "", "", null).then(_ ->
 			persistence.storeStreamManagement("alice@example.com", Bytes.ofHex("01020004").getData(), "ZZ")
@@ -826,4 +953,310 @@ class TestSqlite extends utest.Test {
 			async.done();
 		});
 	}
+
+	public function testGetMembersHydratesPersistedMemberData(async: Async) {
+		final account = "alice@example.com";
+		final chat = new Channel(cast null, cast null, persistence, "room-members-1@example.com");
+		chat.displayName = "A Chat";
+		chat.trusted = true;
+		final member = new Member(
+			"room-members-1@example.com/occ-1",
+			"Alice",
+			"photo:alice",
+			false,
+			[new Role("admin", "Admin")],
+			JID.parse("alice@example.com"),
+			["laptop" => Stanza.parse('<presence><show>away</show></presence>')],
+			new AvailableChat("alice@example.com", "Alice", "", new borogove.Caps("", [], [], []))
+		);
+
+		persistence.storeMembers(account, chat.chatId, [member]).then(_ ->
+			persistence.getMembers(account, chat, false)
+		).then(result -> {
+			Assert.equals(1, result.length);
+			Assert.equals(member.id, result[0].id);
+			Assert.equals("Alice", result[0].displayName);
+			Assert.equals("alice@example.com", result[0].chat.chatId);
+			Assert.equals("admin", result[0].roles[0].id);
+			Assert.notNull(result[0].presence.get("laptop"));
+			Assert.equals(1, cast(result[0].showPresence, Int));
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testStoreMemberUpdatesMergesExistingMemberData(async: Async) {
+		final account = "alice@example.com";
+		final chat = new Channel(cast null, cast null, persistence, "room-members-2@example.com");
+		chat.displayName = "A Chat";
+
+		persistence.storeMembers(account, chat.chatId, [
+			new Member(
+				"room-members-2@example.com/occ-1",
+				"Alice",
+				null,
+				false,
+				[new Role("admin", "Admin"), new Role("urn:xmpp:hats:test", "Tea Host")],
+				JID.parse("alice@example.com"),
+				["desk" => Stanza.parse("<presence />")],
+				new AvailableChat("alice@example.com", "Alice", "", new borogove.Caps("", [], [], []))
+			)
+		]).then(_ ->
+			persistence.storeMemberUpdates(account, chat, [
+				new MemberUpdate(
+					"room-members-2@example.com/occ-1",
+					JID.parse("alice@example.com"),
+					"Alice Cooper",
+					false,
+					null,
+					["mobile" => Stanza.parse("<presence />")]
+				)
+			], false)
+		).then(result -> {
+			Assert.equals(1, result.length);
+			Assert.equals("Alice Cooper", result[0].displayName);
+			Assert.equals(1, result[0].roles.length);
+			Assert.equals("urn:xmpp:hats:test", result[0].roles[0].id);
+			Assert.notNull(result[0].presence.get("desk"));
+			Assert.notNull(result[0].presence.get("mobile"));
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testStoreMemberUpdatesClearsOmittedFullListAffiliations(async: Async) {
+		final account = "alice@example.com";
+		final chat = new Channel(cast null, cast null, persistence, "room-members-2b@example.com");
+		chat.displayName = "A Chat";
+
+		persistence.storeMembers(account, chat.chatId, [
+			new Member(
+				"room-members-2b@example.com/occ-1",
+				"Alice",
+				null,
+				false,
+				[new Role("admin", "Admin")],
+				JID.parse("alice@example.com"),
+				new Map(),
+				new AvailableChat("alice@example.com", "Alice", "", new borogove.Caps("", [], [], []))
+			),
+			new Member(
+				"room-members-2b@example.com/occ-2",
+				"Bob",
+				null,
+				false,
+				[new Role("owner", "Owner")],
+				JID.parse("bob@example.com"),
+				new Map(),
+				new AvailableChat("bob@example.com", "Bob", "", new borogove.Caps("", [], [], []))
+			)
+		]).then(_ ->
+			persistence.storeMemberUpdates(account, chat, [
+				new MemberUpdate(
+					"room-members-2b@example.com/occ-1",
+					JID.parse("alice@example.com"),
+					"Alice",
+					false,
+					null,
+					new Map()
+				)
+			], true)
+			).then(_ ->
+				persistence.getMembers(account, chat, true)
+			).then(result -> {
+				var bob: Null<Member> = null;
+				for (member in result) {
+					if (member.id == "room-members-2b@example.com/occ-2") {
+						bob = member;
+						break;
+					}
+				}
+				Assert.notNull(bob);
+				if (bob != null) Assert.equals(0, bob.roles.length);
+				async.done();
+			}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testStoreMemberUpdatesMatchesExistingMemberByTrueJid(async: Async) {
+		final account = "alice@example.com";
+		final chat = new Channel(cast null, cast null, persistence, "room-members-3@example.com");
+		chat.displayName = "A Chat";
+
+		persistence.storeMembers(account, chat.chatId, [
+			new Member(
+				"room-members-3@example.com/occ-1",
+				"Alice",
+				null,
+				false,
+				[new Role("admin", "Admin")],
+				JID.parse("alice@example.com"),
+				new Map(),
+				new AvailableChat("alice@example.com", "Alice", "", new borogove.Caps("", [], [], []))
+			)
+		]).then(_ ->
+			persistence.storeMemberUpdates(account, chat, [
+				new MemberUpdate(
+					null,
+					JID.parse("alice@example.com"),
+					"Alice Renamed",
+					false,
+					null,
+					new Map()
+				)
+			], false)
+		).then(_ ->
+			persistence.getMemberDetails(account, chat, ["room-members-3@example.com/occ-1"])
+		).then(result -> {
+			Assert.notNull(result[0]);
+			Assert.equals("Alice Renamed", result[0].displayName);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testClearMemberPresenceOnlyClearsTargetedChat(async: Async) {
+		final account = "alice@example.com";
+		final chat1 = new Channel(cast null, cast null, persistence, "room-members-4a@example.com");
+		chat1.displayName = "A Chat";
+		final chat2 = new Channel(cast null, cast null, persistence, "room-members-4b@example.com");
+		chat2.displayName = "A Chat";
+
+		persistence.storeMembers(account, chat1.chatId, [
+			new Member(
+				"room-members-4a@example.com/occ-1",
+				"Alice",
+				null,
+				false,
+				[new Role("admin", "Admin")],
+				JID.parse("alice@example.com"),
+				["desk" => Stanza.parse("<presence />")],
+				new AvailableChat("alice@example.com", "Alice", "", new borogove.Caps("", [], [], []))
+			)
+		]).then(_ ->
+			persistence.storeMembers(account, chat2.chatId, [
+				new Member(
+					"room-members-4b@example.com/occ-1",
+					"Bob",
+					null,
+					false,
+					[new Role("admin", "Admin")],
+					JID.parse("bob@example.com"),
+					["phone" => Stanza.parse("<presence />")],
+					new AvailableChat("bob@example.com", "Bob", "", new borogove.Caps("", [], [], []))
+				)
+			])
+		).then(_ ->
+			persistence.clearMemberPresence(account, chat1.chatId)
+		).then(_ ->
+			persistence.getMemberDetails(account, chat1, ["room-members-4a@example.com/occ-1"]).then(result1 ->
+				persistence.getMemberDetails(account, chat2, ["room-members-4b@example.com/occ-1"]).then(result2 ->
+					Promise.resolve([result1[0], result2[0]])
+				)
+			)
+		).then(result -> {
+			Assert.notNull(result[0]);
+			Assert.notNull(result[1]);
+			Assert.equals(0, result[0].presence.keys().hasNext() ? 1 : 0);
+			Assert.notNull(result[1].presence.get("phone"));
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testGetMembersFiltersHiddenRowsForNonModerators(async: Async) {
+		final account = "alice@example.com";
+		final chat = new Channel(cast null, cast null, persistence, "room-members-5@example.com");
+		chat.displayName = "A Chat";
+
+		persistence.storeMembers(account, chat.chatId, [
+			new Member("room-members-5@example.com/owner", "Zulu", null, false, [new Role("owner", "Owner")], JID.parse("zulu@example.com"), ["desk" => Stanza.parse("<presence />")], new AvailableChat("zulu@example.com", "Zulu", "", new borogove.Caps("", [], [], []))),
+			new Member("room-members-5@example.com/outcast", "Banned", null, false, [new Role("outcast", "Banned")], JID.parse("banned@example.com"), ["desk" => Stanza.parse("<presence />")], new AvailableChat("banned@example.com", "Banned", "", new borogove.Caps("", [], [], []))),
+			new Member("room-members-5@example.com/guest-offline", "Guest", null, false, [new Role("none", "Guest")], JID.parse("guest@example.com"), ["desk" => Stanza.parse('<presence type="unavailable" />')], new AvailableChat("guest@example.com", "Guest", "", new borogove.Caps("", [], [], []))),
+			new Member("room-members-5@example.com/admin", "Alpha", null, false, [new Role("admin", "Admin")], JID.parse("alpha@example.com"), ["desk" => Stanza.parse("<presence />")], new AvailableChat("alpha@example.com", "Alpha", "", new borogove.Caps("", [], [], [])))
+		]).then(_ ->
+			persistence.getMembers(account, chat, false)
+		).then(result -> {
+			Assert.same(["Zulu", "Alpha"], result.map(m -> m.displayName));
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testGetMembersIncludesModeratorVisibleRows(async: Async) {
+		final account = "alice@example.com";
+		final chat = new Channel(cast null, cast null, persistence, "room-members-6@example.com");
+		chat.displayName = "A Chat";
+
+		persistence.storeMembers(account, chat.chatId, [
+			new Member("room-members-6@example.com/owner", "Zulu", null, false, [new Role("owner", "Owner")], JID.parse("zulu@example.com"), ["desk" => Stanza.parse("<presence />")], new AvailableChat("zulu@example.com", "Zulu", "", new borogove.Caps("", [], [], []))),
+			new Member("room-members-6@example.com/outcast", "Banned", null, false, [new Role("outcast", "Banned")], JID.parse("banned@example.com"), ["desk" => Stanza.parse("<presence />")], new AvailableChat("banned@example.com", "Banned", "", new borogove.Caps("", [], [], []))),
+			new Member("room-members-6@example.com/guest-offline", "Guest", null, false, [new Role("none", "Guest")], JID.parse("guest@example.com"), ["desk" => Stanza.parse('<presence type="unavailable" />')], new AvailableChat("guest@example.com", "Guest", "", new borogove.Caps("", [], [], []))),
+			new Member("room-members-6@example.com/admin", "Alpha", null, false, [new Role("admin", "Admin")], JID.parse("alpha@example.com"), ["desk" => Stanza.parse("<presence />")], new AvailableChat("alpha@example.com", "Alpha", "", new borogove.Caps("", [], [], [])))
+		]).then(_ ->
+			persistence.getMembers(account, chat, true)
+		).then(result -> {
+			Assert.same(["Zulu", "Alpha", "Banned"], result.map(m -> m.displayName));
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
+
+	public function testGetMemberDetailsReturnsNullForIncompleteRows(async: Async) {
+		final account = "alice@example.com";
+		final chat = new Channel(cast null, cast null, persistence, "room-members-7@example.com");
+		chat.displayName = "A Chat";
+
+		persistence.storeMembers(account, chat.chatId, [
+			new Member(
+				"room-members-7@example.com/admin",
+				"Alpha",
+				null,
+				false,
+				[new Role("admin", "Admin")],
+				JID.parse("alpha@example.com"),
+				["desk" => Stanza.parse("<presence />")],
+				new AvailableChat("alpha@example.com", "Alpha", "", new borogove.Caps("", [], [], []))
+			)
+		]).then(_ -> {
+			return untyped persistence.db.exec('INSERT INTO members(account_id, chat_id, member_id, display_name, photo_uri, is_self, chat, roles, presence, jid) VALUES(?, ?, ?, ?, ?, ?, ?, jsonb(?), jsonb(?), ?)', [
+				account,
+				chat.chatId,
+				"room-members-7@example.com/incomplete",
+				"",
+				null,
+				0,
+				"{}",
+				"[]",
+				"{}",
+				""
+			]);
+		}).then(_ ->
+			persistence.getMemberDetails(account, chat, [
+				"room-members-7@example.com/admin",
+				"room-members-7@example.com/incomplete"
+			])
+		).then(result -> {
+			Assert.equals("Alpha", result[0]?.displayName);
+			Assert.isNull(result[1]);
+			async.done();
+		}).catchError(e -> {
+			Assert.fail(Std.string(e));
+			async.done();
+		});
+	}
 }
diff --git a/test/sqlite.spec.ts b/test/sqlite.spec.ts
index 4213d63..bf73efb 100644
--- a/test/sqlite.spec.ts
+++ b/test/sqlite.spec.ts
@@ -1560,6 +1560,210 @@ test.describe("not webkit", () => {
 		expect(result.statusText).toBe("Time for tea!");
 	});
 
+	test("getChats uses member presence for direct chats", async ({ page }) => {
+		page.route("https://localhost/", (route) =>
+			route.fulfill({
+				body: "<html></html>",
+				headers: {
+					"Cross-Origin-Opener-Policy": "same-origin",
+					"Cross-Origin-Embedder-Policy": "same-origin",
+					"Cross-Origin-Resource-Policy": "same-origin",
+				},
+			}),
+		);
+		const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+		const sqlite = fs.readFileSync("playwright/.cache/sqlite-wasm.js", "utf8");
+		const worker1 = fs.readFileSync(
+			"playwright/.cache/sqlite-worker1.js",
+			"utf8",
+		);
+		await page.goto("https://localhost/");
+		const result = await page.evaluate(
+			async ([code, sqliteCode, worker1Code]) => {
+				const borogove = await import(
+					URL.createObjectURL(new Blob([code], { type: "text/javascript" }))
+				);
+				const sqlite = await import(
+					URL.createObjectURL(
+						new Blob([sqliteCode], { type: "text/javascript" }),
+					)
+				);
+				window.sqliteWorker1Url = new URL(
+					URL.createObjectURL(
+						new Blob([worker1Code], { type: "text/javascript" }),
+					),
+				);
+				const persistence = new sqlite.borogove_persistence_Sqlite(
+					"snikket",
+					await borogove.persistence.MediaStoreCache("snikket"),
+				);
+
+				const chat = new borogove.DirectChat(
+					null,
+					null,
+					persistence,
+					"hatter@example.com",
+				);
+				chat.displayName = "The Mad Hatter";
+				chat.trusted = true;
+
+				try {
+					await persistence.storeChats("alice@example.com", [chat]);
+					await new Promise((resolve) => setTimeout(resolve, 200));
+					await persistence.storeMembers("alice@example.com", chat.chatId, [
+						{
+							id: "hatter@example.com",
+							displayName: "The Mad Hatter",
+							photoUri: null,
+							isSelf: false,
+							roles: [],
+							jid: borogove.JID.parse("hatter@example.com"),
+							presence: new Map([
+								["phone", borogove.Stanza.parse("<presence />")],
+							]),
+							chat: null,
+						},
+					]);
+					const [stored] = await persistence.getChats("alice@example.com");
+					return [...stored.presence.keys()].sort();
+				} catch (e) {
+					console.error(e, e.result);
+					throw e.result ? JSON.stringify(e.result) : e.message;
+				}
+			},
+			[code, sqlite, worker1],
+		);
+
+		expect(result).toEqual(["phone"]);
+	});
+
+	test("getChats hydrates membersForName and mavUntil from members", async ({
+		page,
+	}) => {
+		page.route("https://localhost/", (route) =>
+			route.fulfill({
+				body: "<html></html>",
+				headers: {
+					"Cross-Origin-Opener-Policy": "same-origin",
+					"Cross-Origin-Embedder-Policy": "same-origin",
+					"Cross-Origin-Resource-Policy": "same-origin",
+				},
+			}),
+		);
+		const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+		const sqlite = fs.readFileSync("playwright/.cache/sqlite-wasm.js", "utf8");
+		const worker1 = fs.readFileSync(
+			"playwright/.cache/sqlite-worker1.js",
+			"utf8",
+		);
+		await page.goto("https://localhost/");
+		const result = await page.evaluate(
+			async ([code, sqliteCode, worker1Code]) => {
+				const borogove = await import(
+					URL.createObjectURL(new Blob([code], { type: "text/javascript" }))
+				);
+				const sqlite = await import(
+					URL.createObjectURL(
+						new Blob([sqliteCode], { type: "text/javascript" }),
+					)
+				);
+				window.sqliteWorker1Url = new URL(
+					URL.createObjectURL(
+						new Blob([worker1Code], { type: "text/javascript" }),
+					),
+				);
+				const persistence = new sqlite.borogove_persistence_Sqlite(
+					"snikket",
+					await borogove.persistence.MediaStoreCache("snikket"),
+				);
+
+				const chat = new sqlite.Channel(
+					null,
+					null,
+					persistence,
+					"room-chat-hydrate@example.com",
+				);
+				chat.displayName = "Tea Room";
+				chat.trusted = true;
+				chat.mavUntil = "2024-05-01T12:00:00Z";
+
+				await persistence.storeChats("alice@example.com", [chat]);
+				await new Promise((resolve) => setTimeout(resolve, 200));
+				await persistence.storeMembers("alice@example.com", chat.chatId, [
+					{
+						id: chat.chatId,
+						displayName: "Tea Room",
+						photoUri: null,
+						isSelf: false,
+						roles: [],
+						jid: borogove.JID.parse(chat.chatId),
+						presence: new Map(),
+						chat: null,
+					},
+					{
+						id: `${chat.chatId}/self`,
+						displayName: "Myself",
+						photoUri: null,
+						isSelf: true,
+						roles: [{ id: "owner", title: "Owner" }],
+						jid: borogove.JID.parse("alice@example.com"),
+						presence: new Map([
+							["desk", borogove.Stanza.parse("<presence />")],
+						]),
+						chat: null,
+					},
+					{
+						id: `${chat.chatId}/zulu`,
+						displayName: "Zulu",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "admin", title: "Admin" }],
+						jid: borogove.JID.parse("zulu@example.com"),
+						presence: new Map([
+							["desk", borogove.Stanza.parse("<presence />")],
+						]),
+						chat: { chatId: "zulu@example.com" },
+					},
+					{
+						id: `${chat.chatId}/alpha`,
+						displayName: "Alpha",
+						photoUri: null,
+						isSelf: false,
+						roles: [],
+						jid: borogove.JID.parse("alpha@example.com"),
+						presence: new Map([
+							["desk", borogove.Stanza.parse("<presence />")],
+						]),
+						chat: { chatId: "alpha@example.com" },
+					},
+					{
+						id: `${chat.chatId}/hidden`,
+						displayName: "Hidden",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "none", title: "Guest" }],
+						jid: borogove.JID.parse("hidden@example.com"),
+						presence: new Map([
+							["desk", borogove.Stanza.parse("<presence />")],
+						]),
+						chat: { chatId: "hidden@example.com" },
+					},
+				]);
+				const [stored] = await persistence.getChats("alice@example.com");
+				return {
+					mavUntil: stored.mavUntil,
+					membersForName: stored.membersForName.map((m) => m.displayName),
+					presenceKeys: [...stored.presence.keys()].sort(),
+				};
+			},
+			[code, sqlite, worker1],
+		);
+
+		expect(result.mavUntil).toBe("2024-05-01T12:00:00Z");
+		expect(result.membersForName).toEqual(["Alpha", "Zulu"]);
+		expect(result.presenceKeys).toEqual(["desk"]);
+	});
+
 	test("storeStreamManamagement and getStreamManagement", async ({ page }) => {
 		page.route("https://localhost/", (route) =>
 			route.fulfill({
@@ -1600,9 +1804,20 @@ test.describe("not webkit", () => {
 
 				try {
 					await persistence.storeLogin("alice@example.com", "", "", null); // or updating with SM may not work
-					await persistence.storeStreamManagement("alice@example.com", new Uint8Array([1,2,0,4]).buffer, "ZZ");
-					const result = await persistence.getStreamManagement("alice@example.com");
-					return { smIsArrayBuffer: result.sm instanceof ArrayBuffer, smIsEq: result.sm ? indexedDB.cmp(result.sm, new Uint8Array([1,2,0,4]).buffer) : "null", sortId: result.sortId };
+					await persistence.storeStreamManagement(
+						"alice@example.com",
+						new Uint8Array([1, 2, 0, 4]).buffer,
+						"ZZ",
+					);
+					const result =
+						await persistence.getStreamManagement("alice@example.com");
+					return {
+						smIsArrayBuffer: result.sm instanceof ArrayBuffer,
+						smIsEq: result.sm
+							? indexedDB.cmp(result.sm, new Uint8Array([1, 2, 0, 4]).buffer)
+							: "null",
+						sortId: result.sortId,
+					};
 				} catch (e) {
 					console.error(e, e.result);
 					throw e.result ? JSON.stringify(e.result) : e.message;
@@ -1615,4 +1830,812 @@ test.describe("not webkit", () => {
 		expect(result.smIsArrayBuffer).toBe(true);
 		expect(result.sortId).toBe("ZZ");
 	});
+
+	test("getMembers hydrates persisted member data", async ({ page }) => {
+		page.route("https://localhost/", (route) =>
+			route.fulfill({
+				body: "<html></html>",
+				headers: {
+					"Cross-Origin-Opener-Policy": "same-origin",
+					"Cross-Origin-Embedder-Policy": "same-origin",
+					"Cross-Origin-Resource-Policy": "same-origin",
+				},
+			}),
+		);
+		const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+		const sqlite = fs.readFileSync("playwright/.cache/sqlite-wasm.js", "utf8");
+		const worker1 = fs.readFileSync(
+			"playwright/.cache/sqlite-worker1.js",
+			"utf8",
+		);
+		await page.goto("https://localhost/");
+		const result = await page.evaluate(
+			async ([code, sqliteCode, worker1Code]) => {
+				const borogove = await import(
+					URL.createObjectURL(new Blob([code], { type: "text/javascript" }))
+				);
+				const sqlite = await import(
+					URL.createObjectURL(
+						new Blob([sqliteCode], { type: "text/javascript" }),
+					)
+				);
+				window.sqliteWorker1Url = new URL(
+					URL.createObjectURL(
+						new Blob([worker1Code], { type: "text/javascript" }),
+					),
+				);
+				const persistence = new sqlite.borogove_persistence_Sqlite(
+					"snikket",
+					await borogove.persistence.MediaStoreCache("snikket"),
+				);
+				const chat = new borogove.Channel(
+					null,
+					null,
+					persistence,
+					"room-members-1@example.com",
+				);
+				chat.displayName = "A Chat";
+				chat.trusted = true;
+
+				await persistence.storeMembers("alice@example.com", chat.chatId, [
+					{
+						id: "room-members-1@example.com/occ-1",
+						displayName: "Alice",
+						photoUri: "photo:alice",
+						isSelf: false,
+						roles: [{ id: "admin", title: "Admin" }],
+						jid: borogove.JID.parse("alice@example.com"),
+						presence: new Map([
+							[
+								"laptop",
+								borogove.Stanza.parse("<presence><show>away</show></presence>"),
+							],
+						]),
+						chat: { chatId: "alice@example.com" },
+					},
+				]);
+
+				const members = await persistence.getMembers(
+					"alice@example.com",
+					chat,
+					false,
+				);
+				return {
+					id: members[0]?.id,
+					displayName: members[0]?.displayName,
+					chatId: members[0]?.chat?.chatId,
+					roleIds: members[0]?.roles?.map((r) => r.id),
+					presenceKeys: members[0] ? [...members[0].presence.keys()] : [],
+					showPresence: members[0]?.showPresence,
+				};
+			},
+			[code, sqlite, worker1],
+		);
+
+		expect(result.id).toBe("room-members-1@example.com/occ-1");
+		expect(result.displayName).toBe("Alice");
+		expect(result.chatId).toBe("alice@example.com");
+		expect(result.roleIds).toEqual(["admin"]);
+		expect(result.presenceKeys).toEqual(["laptop"]);
+		expect(result.showPresence).toBe(1);
+	});
+
+	test("storeMemberUpdates merges existing member data", async ({ page }) => {
+		page.route("https://localhost/", (route) =>
+			route.fulfill({
+				body: "<html></html>",
+				headers: {
+					"Cross-Origin-Opener-Policy": "same-origin",
+					"Cross-Origin-Embedder-Policy": "same-origin",
+					"Cross-Origin-Resource-Policy": "same-origin",
+				},
+			}),
+		);
+		const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+		const sqlite = fs.readFileSync("playwright/.cache/sqlite-wasm.js", "utf8");
+		const worker1 = fs.readFileSync(
+			"playwright/.cache/sqlite-worker1.js",
+			"utf8",
+		);
+		await page.goto("https://localhost/");
+		const result = await page.evaluate(
+			async ([code, sqliteCode, worker1Code]) => {
+				const borogove = await import(
+					URL.createObjectURL(new Blob([code], { type: "text/javascript" }))
+				);
+				const sqlite = await import(
+					URL.createObjectURL(
+						new Blob([sqliteCode], { type: "text/javascript" }),
+					)
+				);
+				window.sqliteWorker1Url = new URL(
+					URL.createObjectURL(
+						new Blob([worker1Code], { type: "text/javascript" }),
+					),
+				);
+				const persistence = new sqlite.borogove_persistence_Sqlite(
+					"snikket",
+					await borogove.persistence.MediaStoreCache("snikket"),
+				);
+				const chat = new borogove.Channel(
+					null,
+					null,
+					persistence,
+					"room-members-2@example.com",
+				);
+				chat.displayName = "A Chat";
+				chat.trusted = true;
+
+				await persistence.storeMembers("alice@example.com", chat.chatId, [
+					{
+						id: "room-members-2@example.com/occ-1",
+						displayName: "Alice",
+						photoUri: null,
+						isSelf: false,
+						roles: [
+							{ id: "admin", title: "Admin" },
+							{ id: "urn:xmpp:hats:test", title: "Tea Host" },
+						],
+						jid: borogove.JID.parse("alice@example.com"),
+						presence: new Map([
+							["desk", borogove.Stanza.parse("<presence />")],
+						]),
+						chat: { chatId: "alice@example.com" },
+					},
+				]);
+
+				const updated = await persistence.storeMemberUpdates(
+					"alice@example.com",
+					chat,
+					[
+						new borogove.MemberUpdate(
+							"room-members-2@example.com/occ-1",
+							borogove.JID.parse("alice@example.com"),
+							"Alice Cooper",
+							false,
+							null,
+							new Map([["mobile", borogove.Stanza.parse("<presence />")]]),
+						),
+					],
+					false,
+				);
+
+				return {
+					displayName: updated[0]?.displayName,
+					roleIds: updated[0]?.roles?.map((r) => r.id),
+					presenceKeys: updated[0]
+						? [...updated[0].presence.keys()].sort()
+						: [],
+				};
+			},
+			[code, sqlite, worker1],
+		);
+
+		expect(result.displayName).toBe("Alice Cooper");
+		expect(result.roleIds).toEqual(["urn:xmpp:hats:test"]);
+		expect(result.presenceKeys).toEqual(["desk", "mobile"]);
+	});
+
+	test("storeMemberUpdates clears omitted full-list affiliations", async ({
+		page,
+	}) => {
+		page.route("https://localhost/", (route) =>
+			route.fulfill({
+				body: "<html></html>",
+				headers: {
+					"Cross-Origin-Opener-Policy": "same-origin",
+					"Cross-Origin-Embedder-Policy": "same-origin",
+					"Cross-Origin-Resource-Policy": "same-origin",
+				},
+			}),
+		);
+		const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+		const sqlite = fs.readFileSync("playwright/.cache/sqlite-wasm.js", "utf8");
+		const worker1 = fs.readFileSync(
+			"playwright/.cache/sqlite-worker1.js",
+			"utf8",
+		);
+		await page.goto("https://localhost/");
+		const result = await page.evaluate(
+			async ([code, sqliteCode, worker1Code]) => {
+				const borogove = await import(
+					URL.createObjectURL(new Blob([code], { type: "text/javascript" }))
+				);
+				const sqlite = await import(
+					URL.createObjectURL(
+						new Blob([sqliteCode], { type: "text/javascript" }),
+					)
+				);
+				window.sqliteWorker1Url = new URL(
+					URL.createObjectURL(
+						new Blob([worker1Code], { type: "text/javascript" }),
+					),
+				);
+				const persistence = new sqlite.borogove_persistence_Sqlite(
+					"snikket",
+					await borogove.persistence.MediaStoreCache("snikket"),
+				);
+				const chat = new borogove.Channel(
+					null,
+					null,
+					persistence,
+					"room-members-2b@example.com",
+				);
+				chat.displayName = "A Chat";
+				chat.trusted = true;
+
+				await persistence.storeMembers("alice@example.com", chat.chatId, [
+					{
+						id: "room-members-2b@example.com/occ-1",
+						displayName: "Alice",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "admin", title: "Admin" }],
+						jid: borogove.JID.parse("alice@example.com"),
+						presence: new Map(),
+						chat: { chatId: "alice@example.com" },
+					},
+					{
+						id: "room-members-2b@example.com/occ-2",
+						displayName: "Bob",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "owner", title: "Owner" }],
+						jid: borogove.JID.parse("bob@example.com"),
+						presence: new Map(),
+						chat: { chatId: "bob@example.com" },
+					},
+				]);
+
+				await persistence.storeMemberUpdates(
+					"alice@example.com",
+					chat,
+					[
+						new borogove.MemberUpdate(
+							"room-members-2b@example.com/occ-1",
+							borogove.JID.parse("alice@example.com"),
+							"Alice",
+							false,
+							null,
+							new Map(),
+						),
+					],
+					true,
+				);
+				const members = await persistence.getMembers(
+					"alice@example.com",
+					chat,
+					true,
+				);
+				return members
+					.find((m) => m.id.endsWith("occ-2"))
+					?.roles?.map((r) => r.id);
+			},
+			[code, sqlite, worker1],
+		);
+
+		expect(result).toEqual([]);
+	});
+
+	test("storeMemberUpdates matches existing member by true JID", async ({
+		page,
+	}) => {
+		page.route("https://localhost/", (route) =>
+			route.fulfill({
+				body: "<html></html>",
+				headers: {
+					"Cross-Origin-Opener-Policy": "same-origin",
+					"Cross-Origin-Embedder-Policy": "same-origin",
+					"Cross-Origin-Resource-Policy": "same-origin",
+				},
+			}),
+		);
+		const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+		const sqlite = fs.readFileSync("playwright/.cache/sqlite-wasm.js", "utf8");
+		const worker1 = fs.readFileSync(
+			"playwright/.cache/sqlite-worker1.js",
+			"utf8",
+		);
+		await page.goto("https://localhost/");
+		const result = await page.evaluate(
+			async ([code, sqliteCode, worker1Code]) => {
+				const borogove = await import(
+					URL.createObjectURL(new Blob([code], { type: "text/javascript" }))
+				);
+				const sqlite = await import(
+					URL.createObjectURL(
+						new Blob([sqliteCode], { type: "text/javascript" }),
+					)
+				);
+				window.sqliteWorker1Url = new URL(
+					URL.createObjectURL(
+						new Blob([worker1Code], { type: "text/javascript" }),
+					),
+				);
+				const persistence = new sqlite.borogove_persistence_Sqlite(
+					"snikket",
+					await borogove.persistence.MediaStoreCache("snikket"),
+				);
+				const chat = new borogove.Channel(
+					null,
+					null,
+					persistence,
+					"room-members-3@example.com",
+				);
+				chat.displayName = "A Chat";
+				chat.trusted = true;
+
+				await persistence.storeMembers("alice@example.com", chat.chatId, [
+					{
+						id: "room-members-3@example.com/occ-1",
+						displayName: "Alice",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "admin", title: "Admin" }],
+						jid: borogove.JID.parse("alice@example.com"),
+						presence: new Map(),
+						chat: { chatId: "alice@example.com" },
+					},
+				]);
+
+				await persistence.storeMemberUpdates(
+					"alice@example.com",
+					chat,
+					[
+						new borogove.MemberUpdate(
+							null,
+							borogove.JID.parse("alice@example.com"),
+							"Alice Renamed",
+							false,
+							null,
+							new Map(),
+						),
+					],
+					false,
+				);
+				const [member] = await persistence.getMemberDetails(
+					"alice@example.com",
+					chat,
+					["room-members-3@example.com/occ-1"],
+				);
+				return member?.displayName;
+			},
+			[code, sqlite, worker1],
+		);
+
+		expect(result).toBe("Alice Renamed");
+	});
+
+	test("clearMemberPresence only clears the targeted chat", async ({
+		page,
+	}) => {
+		page.route("https://localhost/", (route) =>
+			route.fulfill({
+				body: "<html></html>",
+				headers: {
+					"Cross-Origin-Opener-Policy": "same-origin",
+					"Cross-Origin-Embedder-Policy": "same-origin",
+					"Cross-Origin-Resource-Policy": "same-origin",
+				},
+			}),
+		);
+		const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+		const sqlite = fs.readFileSync("playwright/.cache/sqlite-wasm.js", "utf8");
+		const worker1 = fs.readFileSync(
+			"playwright/.cache/sqlite-worker1.js",
+			"utf8",
+		);
+		await page.goto("https://localhost/");
+		const result = await page.evaluate(
+			async ([code, sqliteCode, worker1Code]) => {
+				const borogove = await import(
+					URL.createObjectURL(new Blob([code], { type: "text/javascript" }))
+				);
+				const sqlite = await import(
+					URL.createObjectURL(
+						new Blob([sqliteCode], { type: "text/javascript" }),
+					)
+				);
+				window.sqliteWorker1Url = new URL(
+					URL.createObjectURL(
+						new Blob([worker1Code], { type: "text/javascript" }),
+					),
+				);
+				const persistence = new sqlite.borogove_persistence_Sqlite(
+					"snikket",
+					await borogove.persistence.MediaStoreCache("snikket"),
+				);
+				const chat1 = new borogove.Channel(
+					null,
+					null,
+					persistence,
+					"room-members-4a@example.com",
+				);
+				chat1.displayName = "A Chat";
+				chat1.trusted = true;
+				const chat2 = new borogove.Channel(
+					null,
+					null,
+					persistence,
+					"room-members-4b@example.com",
+				);
+				chat2.displayName = "A Chat";
+				chat2.trusted = true;
+
+				await persistence.storeMembers("alice@example.com", chat1.chatId, [
+					{
+						id: "room-members-4a@example.com/occ-1",
+						displayName: "Alice",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "admin", title: "Admin" }],
+						jid: borogove.JID.parse("alice@example.com"),
+						presence: new Map([
+							["desk", borogove.Stanza.parse("<presence />")],
+						]),
+						chat: { chatId: "alice@example.com" },
+					},
+				]);
+				await persistence.storeMembers("alice@example.com", chat2.chatId, [
+					{
+						id: "room-members-4b@example.com/occ-1",
+						displayName: "Bob",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "admin", title: "Admin" }],
+						jid: borogove.JID.parse("bob@example.com"),
+						presence: new Map([
+							["phone", borogove.Stanza.parse("<presence />")],
+						]),
+						chat: { chatId: "bob@example.com" },
+					},
+				]);
+
+				await persistence.clearMemberPresence(
+					"alice@example.com",
+					chat1.chatId,
+				);
+				const [member1] = await persistence.getMemberDetails(
+					"alice@example.com",
+					chat1,
+					["room-members-4a@example.com/occ-1"],
+				);
+				const [member2] = await persistence.getMemberDetails(
+					"alice@example.com",
+					chat2,
+					["room-members-4b@example.com/occ-1"],
+				);
+				return {
+					chat1PresenceKeys: member1 ? [...member1.presence.keys()] : [],
+					chat2PresenceKeys: member2 ? [...member2.presence.keys()] : [],
+				};
+			},
+			[code, sqlite, worker1],
+		);
+
+		expect(result.chat1PresenceKeys).toEqual([]);
+		expect(result.chat2PresenceKeys).toEqual(["phone"]);
+	});
+
+	test("getMembers filters hidden rows for non-moderators", async ({
+		page,
+	}) => {
+		page.route("https://localhost/", (route) =>
+			route.fulfill({
+				body: "<html></html>",
+				headers: {
+					"Cross-Origin-Opener-Policy": "same-origin",
+					"Cross-Origin-Embedder-Policy": "same-origin",
+					"Cross-Origin-Resource-Policy": "same-origin",
+				},
+			}),
+		);
+		const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+		const sqlite = fs.readFileSync("playwright/.cache/sqlite-wasm.js", "utf8");
+		const worker1 = fs.readFileSync(
+			"playwright/.cache/sqlite-worker1.js",
+			"utf8",
+		);
+		await page.goto("https://localhost/");
+		const result = await page.evaluate(
+			async ([code, sqliteCode, worker1Code]) => {
+				const borogove = await import(
+					URL.createObjectURL(new Blob([code], { type: "text/javascript" }))
+				);
+				const sqlite = await import(
+					URL.createObjectURL(
+						new Blob([sqliteCode], { type: "text/javascript" }),
+					)
+				);
+				window.sqliteWorker1Url = new URL(
+					URL.createObjectURL(
+						new Blob([worker1Code], { type: "text/javascript" }),
+					),
+				);
+				const persistence = new sqlite.borogove_persistence_Sqlite(
+					"snikket",
+					await borogove.persistence.MediaStoreCache("snikket"),
+				);
+				const chat = new borogove.Channel(
+					null,
+					null,
+					persistence,
+					"room-members-5@example.com",
+				);
+				chat.displayName = "A Chat";
+				chat.trusted = true;
+
+				await persistence.storeMembers("alice@example.com", chat.chatId, [
+					{
+						id: "room-members-5@example.com/owner",
+						displayName: "Zulu",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "owner", title: "Owner" }],
+						jid: borogove.JID.parse("zulu@example.com"),
+						presence: new Map([
+							["desk", borogove.Stanza.parse("<presence />")],
+						]),
+						chat: { chatId: "zulu@example.com" },
+					},
+					{
+						id: "room-members-5@example.com/outcast",
+						displayName: "Banned",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "outcast", title: "Banned" }],
+						jid: borogove.JID.parse("banned@example.com"),
+						presence: new Map([
+							["desk", borogove.Stanza.parse("<presence />")],
+						]),
+						chat: { chatId: "banned@example.com" },
+					},
+					{
+						id: "room-members-5@example.com/guest-offline",
+						displayName: "Guest",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "none", title: "Guest" }],
+						jid: borogove.JID.parse("guest@example.com"),
+						presence: new Map([
+							[
+								"desk",
+								borogove.Stanza.parse('<presence type="unavailable" />'),
+							],
+						]),
+						chat: { chatId: "guest@example.com" },
+					},
+					{
+						id: "room-members-5@example.com/admin",
+						displayName: "Alpha",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "admin", title: "Admin" }],
+						jid: borogove.JID.parse("alpha@example.com"),
+						presence: new Map([
+							["desk", borogove.Stanza.parse("<presence />")],
+						]),
+						chat: { chatId: "alpha@example.com" },
+					},
+				]);
+
+				const members = await persistence.getMembers(
+					"alice@example.com",
+					chat,
+					false,
+				);
+				return members.map((m) => m.displayName);
+			},
+			[code, sqlite, worker1],
+		);
+
+		expect(result).toEqual(["Zulu", "Alpha"]);
+	});
+
+	test("getMembers includes moderator-visible rows", async ({ page }) => {
+		page.route("https://localhost/", (route) =>
+			route.fulfill({
+				body: "<html></html>",
+				headers: {
+					"Cross-Origin-Opener-Policy": "same-origin",
+					"Cross-Origin-Embedder-Policy": "same-origin",
+					"Cross-Origin-Resource-Policy": "same-origin",
+				},
+			}),
+		);
+		const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+		const sqlite = fs.readFileSync("playwright/.cache/sqlite-wasm.js", "utf8");
+		const worker1 = fs.readFileSync(
+			"playwright/.cache/sqlite-worker1.js",
+			"utf8",
+		);
+		await page.goto("https://localhost/");
+		const result = await page.evaluate(
+			async ([code, sqliteCode, worker1Code]) => {
+				const borogove = await import(
+					URL.createObjectURL(new Blob([code], { type: "text/javascript" }))
+				);
+				const sqlite = await import(
+					URL.createObjectURL(
+						new Blob([sqliteCode], { type: "text/javascript" }),
+					)
+				);
+				window.sqliteWorker1Url = new URL(
+					URL.createObjectURL(
+						new Blob([worker1Code], { type: "text/javascript" }),
+					),
+				);
+				const persistence = new sqlite.borogove_persistence_Sqlite(
+					"snikket",
+					await borogove.persistence.MediaStoreCache("snikket"),
+				);
+				const chat = new borogove.Channel(
+					null,
+					null,
+					persistence,
+					"room-members-6@example.com",
+				);
+				chat.displayName = "A Chat";
+				chat.trusted = true;
+
+				await persistence.storeMembers("alice@example.com", chat.chatId, [
+					{
+						id: "room-members-6@example.com/owner",
+						displayName: "Zulu",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "owner", title: "Owner" }],
+						jid: borogove.JID.parse("zulu@example.com"),
+						presence: new Map([
+							["desk", borogove.Stanza.parse("<presence />")],
+						]),
+						chat: { chatId: "zulu@example.com" },
+					},
+					{
+						id: "room-members-6@example.com/outcast",
+						displayName: "Banned",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "outcast", title: "Banned" }],
+						jid: borogove.JID.parse("banned@example.com"),
+						presence: new Map([
+							["desk", borogove.Stanza.parse("<presence />")],
+						]),
+						chat: { chatId: "banned@example.com" },
+					},
+					{
+						id: "room-members-6@example.com/guest-offline",
+						displayName: "Guest",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "none", title: "Guest" }],
+						jid: borogove.JID.parse("guest@example.com"),
+						presence: new Map([
+							[
+								"desk",
+								borogove.Stanza.parse('<presence type="unavailable" />'),
+							],
+						]),
+						chat: { chatId: "guest@example.com" },
+					},
+					{
+						id: "room-members-6@example.com/admin",
+						displayName: "Alpha",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "admin", title: "Admin" }],
+						jid: borogove.JID.parse("alpha@example.com"),
+						presence: new Map([
+							["desk", borogove.Stanza.parse("<presence />")],
+						]),
+						chat: { chatId: "alpha@example.com" },
+					},
+				]);
+
+				const members = await persistence.getMembers(
+					"alice@example.com",
+					chat,
+					true,
+				);
+				return members.map((m) => m.displayName);
+			},
+			[code, sqlite, worker1],
+		);
+
+		expect(result).toEqual(["Zulu", "Alpha", "Banned"]);
+	});
+
+	test("getMemberDetails returns null for incomplete rows", async ({
+		page,
+	}) => {
+		page.route("https://localhost/", (route) =>
+			route.fulfill({
+				body: "<html></html>",
+				headers: {
+					"Cross-Origin-Opener-Policy": "same-origin",
+					"Cross-Origin-Embedder-Policy": "same-origin",
+					"Cross-Origin-Resource-Policy": "same-origin",
+				},
+			}),
+		);
+		const code = fs.readFileSync("playwright/.cache/borogove.js", "utf8");
+		const sqlite = fs.readFileSync("playwright/.cache/sqlite-wasm.js", "utf8");
+		const worker1 = fs.readFileSync(
+			"playwright/.cache/sqlite-worker1.js",
+			"utf8",
+		);
+		await page.goto("https://localhost/");
+		const result = await page.evaluate(
+			async ([code, sqliteCode, worker1Code]) => {
+				const borogove = await import(
+					URL.createObjectURL(new Blob([code], { type: "text/javascript" }))
+				);
+				const sqlite = await import(
+					URL.createObjectURL(
+						new Blob([sqliteCode], { type: "text/javascript" }),
+					)
+				);
+				window.sqliteWorker1Url = new URL(
+					URL.createObjectURL(
+						new Blob([worker1Code], { type: "text/javascript" }),
+					),
+				);
+				const persistence = new sqlite.borogove_persistence_Sqlite(
+					"snikket",
+					await borogove.persistence.MediaStoreCache("snikket"),
+				);
+				const chat = new borogove.Channel(
+					null,
+					null,
+					persistence,
+					"room-members-7@example.com",
+				);
+				chat.displayName = "A Chat";
+				chat.trusted = true;
+
+				await persistence.storeMembers("alice@example.com", chat.chatId, [
+					{
+						id: "room-members-7@example.com/admin",
+						displayName: "Alpha",
+						photoUri: null,
+						isSelf: false,
+						roles: [{ id: "admin", title: "Admin" }],
+						jid: borogove.JID.parse("alpha@example.com"),
+						presence: new Map([
+							["desk", borogove.Stanza.parse("<presence />")],
+						]),
+						chat: { chatId: "alpha@example.com" },
+					},
+				]);
+
+				await persistence.db.exec(
+					"INSERT INTO members(account_id, chat_id, member_id, display_name, photo_uri, is_self, chat, roles, presence, jid) VALUES(?, ?, ?, ?, ?, ?, ?, jsonb(?), jsonb(?), ?)",
+					[
+						"alice@example.com",
+						chat.chatId,
+						"room-members-7@example.com/incomplete",
+						"",
+						null,
+						0,
+						"{}",
+						"[]",
+						"{}",
+						"",
+					],
+				);
+				const details = await persistence.getMemberDetails(
+					"alice@example.com",
+					chat,
+					[
+						"room-members-7@example.com/admin",
+						"room-members-7@example.com/incomplete",
+					],
+				);
+				return details.map((m) => (m ? m.displayName : null));
+			},
+			[code, sqlite, worker1],
+		);
+
+		expect(result).toEqual(["Alpha", null]);
+	});
 });