Files
Toju/toju-app/src/app/infrastructure/persistence/browser-database.service.ts
T
2026-06-05 18:34:01 +02:00

625 lines
20 KiB
TypeScript

/* eslint-disable, @typescript-eslint/no-non-null-assertion */
import { Injectable } from '@angular/core';
import {
DELETED_MESSAGE_CONTENT,
Message,
User,
Room,
Reaction,
BanEntry,
type MessageRevision
} from '../../shared-kernel';
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
import type { RoomMessageStats } from './database.service';
import { applyBrowserDatabaseSchema, BROWSER_DATABASE_VERSION } from './browser-database-schema';
/** IndexedDB database name for the MetoYou application. */
const DATABASE_NAME = 'metoyou';
const ANONYMOUS_DATABASE_SCOPE = 'anonymous';
/** Names of every object store used by the application. */
const STORE_MESSAGES = 'messages';
const STORE_USERS = 'users';
const STORE_ROOMS = 'rooms';
const STORE_REACTIONS = 'reactions';
const STORE_BANS = 'bans';
const STORE_META = 'meta';
const STORE_ATTACHMENTS = 'attachments';
const STORE_CUSTOM_EMOJIS = 'customEmojis';
const STORE_MESSAGE_REVISIONS = 'messageRevisions';
/** All object store names, used when clearing the entire database. */
const ALL_STORE_NAMES: string[] = [
STORE_MESSAGES,
STORE_USERS,
STORE_ROOMS,
STORE_REACTIONS,
STORE_BANS,
STORE_ATTACHMENTS,
STORE_CUSTOM_EMOJIS,
STORE_MESSAGE_REVISIONS,
STORE_META
];
/**
* IndexedDB-backed database service used when the app runs in a
* plain browser (i.e. without Electron).
*
* Every public method mirrors the {@link DatabaseService} API so the
* facade can delegate transparently.
*/
@Injectable({ providedIn: 'root' })
export class BrowserDatabaseService {
/** Handle to the opened IndexedDB database, or `null` before {@link initialize}. */
private database: IDBDatabase | null = null;
private activeDatabaseName: string | null = null;
/** Open (or create) the IndexedDB database. Safe to call multiple times. */
async initialize(): Promise<void> {
const databaseName = await this.resolveDatabaseName();
if (this.database && this.activeDatabaseName === databaseName)
return;
this.closeDatabase();
this.database = await this.openDatabase(databaseName);
this.activeDatabaseName = databaseName;
}
/** Persist a single message. */
async saveMessage(message: Message): Promise<void> {
await this.put(STORE_MESSAGES, message);
}
async saveMessageRevision(revision: MessageRevision): Promise<void> {
await this.put(STORE_MESSAGE_REVISIONS, {
...revision,
id: `${revision.messageId}:${revision.revision}`
});
}
/**
* Retrieve the latest messages for a room, sorted oldest-first for display.
* @param roomId - Target room.
* @param limit - Maximum number of messages to return.
* @param offset - Number of newer messages to skip (for pagination).
* @param channelId - Optional channel scope; 'general' includes null/empty.
* @param beforeTimestamp - Optional cursor; only messages strictly older
* than this timestamp are returned. Used for
* scroll-up history pagination.
*/
async getMessages(
roomId: string,
limit = 100,
offset = 0,
channelId?: string,
beforeTimestamp?: number
): Promise<Message[]> {
const allRoomMessages = await this.getAllFromIndex<Message>(
STORE_MESSAGES, 'roomId', roomId
);
const scopedMessages = channelId
? allRoomMessages.filter((message) => (message.channelId || 'general') === channelId)
: allRoomMessages;
const cursorFiltered = beforeTimestamp === undefined
? scopedMessages
: scopedMessages.filter((message) => message.timestamp < beforeTimestamp);
const sortedMessages = cursorFiltered.sort((first, second) => first.timestamp - second.timestamp);
const endIndex = Math.max(sortedMessages.length - offset, 0);
const startIndex = Math.max(endIndex - limit, 0);
const messages = sortedMessages.slice(startIndex, endIndex);
return this.hydrateMessages(messages);
}
async getMessagesSince(roomId: string, sinceTimestamp: number): Promise<Message[]> {
const allRoomMessages = await this.getAllFromIndex<Message>(
STORE_MESSAGES, 'roomId', roomId
);
const messages = allRoomMessages
.filter((message) => message.timestamp > sinceTimestamp)
.sort((first, second) => first.timestamp - second.timestamp);
return this.hydrateMessages(messages);
}
async getRoomMessageStats(roomId: string): Promise<RoomMessageStats> {
return this.foldMessagesForRoom(roomId, (stats, message) => {
stats.count += 1;
stats.lastUpdated = Math.max(stats.lastUpdated, message.editedAt || message.timestamp || 0);
}, { count: 0,
lastUpdated: 0 });
}
/** Delete a message by its ID. */
async deleteMessage(messageId: string): Promise<void> {
await this.deleteRecord(STORE_MESSAGES, messageId);
}
/** Apply partial updates to an existing message. */
async updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
const existing = await this.get<Message>(STORE_MESSAGES, messageId);
if (existing) {
await this.put(STORE_MESSAGES, { ...existing,
...updates });
}
}
/** Retrieve a single message by ID, or `null` if not found. */
async getMessageById(messageId: string): Promise<Message | null> {
const message = await this.get<Message>(STORE_MESSAGES, messageId);
if (!message) {
return null;
}
return (await this.hydrateMessages([message]))[0] ?? null;
}
/** Remove every message belonging to a room. */
async clearRoomMessages(roomId: string): Promise<void> {
const messages = await this.getAllFromIndex<Message>(
STORE_MESSAGES, 'roomId', roomId
);
const transaction = this.createTransaction(STORE_MESSAGES, 'readwrite');
for (const message of messages) {
transaction.objectStore(STORE_MESSAGES).delete(message.id);
}
await this.awaitTransaction(transaction);
}
/**
* Persist a reaction, ignoring duplicates (same user + same emoji on
* the same message).
*/
async saveReaction(reaction: Reaction): Promise<void> {
const existing = await this.getAllFromIndex<Reaction>(
STORE_REACTIONS, 'messageId', reaction.messageId
);
const isDuplicate = existing.some(
(entry) => entry.userId === reaction.userId && entry.emoji === reaction.emoji
);
if (!isDuplicate) {
await this.put(STORE_REACTIONS, reaction);
}
}
/** Remove a specific reaction (identified by user + emoji + message). */
async removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
const reactions = await this.getAllFromIndex<Reaction>(
STORE_REACTIONS, 'messageId', messageId
);
const target = reactions.find(
(entry) => entry.userId === userId && entry.emoji === emoji
);
if (target) {
await this.deleteRecord(STORE_REACTIONS, target.id);
}
}
/** Return all reactions for a given message. */
async getReactionsForMessage(messageId: string): Promise<Reaction[]> {
return this.getAllFromIndex<Reaction>(STORE_REACTIONS, 'messageId', messageId);
}
/** Persist a user record. */
async saveUser(user: User): Promise<void> {
await this.put(STORE_USERS, user);
}
/** Retrieve a user by ID, or `null` if not found. */
async getUser(userId: string): Promise<User | null> {
return (await this.get<User>(STORE_USERS, userId)) ?? null;
}
/** Retrieve the last-authenticated ("current") user, or `null`. */
async getCurrentUser(): Promise<User | null> {
const meta = await this.get<{ id: string; value: string }>(
STORE_META, 'currentUserId'
);
if (!meta)
return null;
return this.getUser(meta.value);
}
/** Retrieve the persisted current user ID without loading the full user. */
async getCurrentUserId(): Promise<string | null> {
const meta = await this.get<{ id: string; value: string }>(
STORE_META, 'currentUserId'
);
return meta?.value?.trim() || null;
}
/** Store which user ID is considered "current" (logged-in). */
async setCurrentUserId(userId: string): Promise<void> {
await this.put(STORE_META, { id: 'currentUserId',
value: userId });
}
/**
* Retrieve all known users.
* @param _roomId - Accepted for API parity but currently unused.
*/
async getUsersByRoom(_roomId: string): Promise<User[]> {
return this.getAll<User>(STORE_USERS);
}
/** Apply partial updates to an existing user. */
async updateUser(userId: string, updates: Partial<User>): Promise<void> {
const existing = await this.get<User>(STORE_USERS, userId);
if (existing) {
await this.put(STORE_USERS, { ...existing,
...updates });
}
}
/** Persist a room record. */
async saveRoom(room: Room): Promise<void> {
await this.put(STORE_ROOMS, room);
}
/** Retrieve a room by ID, or `null` if not found. */
async getRoom(roomId: string): Promise<Room | null> {
return (await this.get<Room>(STORE_ROOMS, roomId)) ?? null;
}
/** Return every persisted room. */
async getAllRooms(): Promise<Room[]> {
return this.getAll<Room>(STORE_ROOMS);
}
/** Delete a room and all of its messages. */
async deleteRoom(roomId: string): Promise<void> {
await this.deleteRecord(STORE_ROOMS, roomId);
await this.clearRoomMessages(roomId);
}
/** Apply partial updates to an existing room. */
async updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
const existing = await this.get<Room>(STORE_ROOMS, roomId);
if (existing) {
await this.put(STORE_ROOMS, { ...existing,
...updates });
}
}
/** Persist a ban entry. */
async saveBan(ban: BanEntry): Promise<void> {
await this.put(STORE_BANS, ban);
}
/** Remove a ban by the banned user's `oderId`. */
async removeBan(oderId: string): Promise<void> {
const allBans = await this.getAll<BanEntry>(STORE_BANS);
const match = allBans.find((ban) => ban.oderId === oderId);
if (match) {
await this.deleteRecord(STORE_BANS, match.oderId);
}
}
/**
* Return active (non-expired) bans for a room.
*
* @param roomId - Room to query.
*/
async getBansForRoom(roomId: string): Promise<BanEntry[]> {
const allBans = await this.getAllFromIndex<BanEntry>(
STORE_BANS, 'roomId', roomId
);
const now = Date.now();
return allBans.filter(
(ban) => !ban.expiresAt || ban.expiresAt > now
);
}
/** Check whether a specific user is currently banned from a room. */
async isUserBanned(userId: string, roomId: string): Promise<boolean> {
const activeBans = await this.getBansForRoom(roomId);
return activeBans.some((ban) => ban.oderId === userId);
}
/** Persist attachment metadata associated with a chat message. */
async saveAttachment(attachment: ChatAttachmentMeta): Promise<void> {
await this.put(STORE_ATTACHMENTS, attachment);
}
/** Return all attachment records associated with a message. */
async getAttachmentsForMessage(messageId: string): Promise<ChatAttachmentMeta[]> {
return this.getAllFromIndex<ChatAttachmentMeta>(STORE_ATTACHMENTS, 'messageId', messageId);
}
/** Return every persisted attachment record. */
async getAllAttachments(): Promise<ChatAttachmentMeta[]> {
return this.getAll<ChatAttachmentMeta>(STORE_ATTACHMENTS);
}
async saveCustomEmoji(emoji: CustomEmoji): Promise<void> {
await this.put(STORE_CUSTOM_EMOJIS, emoji);
}
async getCustomEmojis(): Promise<CustomEmoji[]> {
return this.getAll<CustomEmoji>(STORE_CUSTOM_EMOJIS);
}
async deleteCustomEmoji(emojiId: string): Promise<void> {
await this.deleteRecord(STORE_CUSTOM_EMOJIS, emojiId);
}
/** Delete every attachment record for a specific message. */
async deleteAttachmentsForMessage(messageId: string): Promise<void> {
const attachments = await this.getAllFromIndex<ChatAttachmentMeta>(
STORE_ATTACHMENTS, 'messageId', messageId
);
const transaction = this.createTransaction(STORE_ATTACHMENTS, 'readwrite');
for (const attachment of attachments) {
transaction.objectStore(STORE_ATTACHMENTS).delete(attachment.id);
}
await this.awaitTransaction(transaction);
}
/** Wipe all persisted data in every object store. */
async clearAllData(): Promise<void> {
const transaction = this.createTransaction(ALL_STORE_NAMES, 'readwrite');
for (const storeName of ALL_STORE_NAMES) {
transaction.objectStore(storeName).clear();
}
await this.awaitTransaction(transaction);
}
private async resolveDatabaseName(): Promise<string> {
const currentUserId = getStoredCurrentUserId();
const scopedDatabaseName = this.createScopedDatabaseName(currentUserId);
if (!currentUserId) {
return scopedDatabaseName;
}
if (await this.databaseExists(scopedDatabaseName)) {
return scopedDatabaseName;
}
const legacyCurrentUserId = await this.readCurrentUserIdFromDatabase(DATABASE_NAME);
return legacyCurrentUserId === currentUserId
? DATABASE_NAME
: scopedDatabaseName;
}
private createScopedDatabaseName(userId: string | null): string {
return `${DATABASE_NAME}::${encodeURIComponent(userId || ANONYMOUS_DATABASE_SCOPE)}`;
}
private async databaseExists(name: string): Promise<boolean> {
const hasDatabasesApi = typeof indexedDB.databases === 'function';
if (!hasDatabasesApi) {
return false;
}
const databases = await indexedDB.databases();
return databases.some((database) => database.name === name);
}
private async readCurrentUserIdFromDatabase(databaseName: string): Promise<string | null> {
if (!await this.databaseExists(databaseName)) {
return null;
}
const database = await this.openDatabase(databaseName);
try {
const transaction = database.transaction(STORE_META, 'readonly');
const request = transaction.objectStore(STORE_META).get('currentUserId');
return await new Promise<string | null>((resolve, reject) => {
request.onsuccess = () => resolve((request.result as { value?: string } | undefined)?.value?.trim() || null);
request.onerror = () => reject(request.error);
});
} catch {
return null;
} finally {
database.close();
}
}
private openDatabase(databaseName: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(databaseName, BROWSER_DATABASE_VERSION);
request.onerror = () => reject(request.error);
request.onupgradeneeded = (event) => {
const upgradeTransaction = (event.target as IDBOpenDBRequest).transaction;
if (!upgradeTransaction) {
reject(new Error('IndexedDB upgrade transaction is unavailable'));
return;
}
try {
applyBrowserDatabaseSchema(request.result, upgradeTransaction);
} catch (error) {
upgradeTransaction.abort();
reject(error);
}
};
request.onsuccess = () => resolve(request.result);
});
}
private closeDatabase(): void {
this.database?.close();
this.database = null;
this.activeDatabaseName = null;
}
private createTransaction(
storeNames: string | string[],
mode: IDBTransactionMode
): IDBTransaction {
if (!this.database) {
throw new Error('Database has not been initialized');
}
return this.database.transaction(storeNames, mode);
}
private awaitTransaction(transaction: IDBTransaction): Promise<void> {
return new Promise((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
});
}
private async put(storeName: string, value: unknown): Promise<void> {
const transaction = this.createTransaction(storeName, 'readwrite');
transaction.objectStore(storeName).put(value);
await this.awaitTransaction(transaction);
}
private async get<T>(storeName: string, key: IDBValidKey): Promise<T | undefined> {
const transaction = this.createTransaction(storeName, 'readonly');
const request = transaction.objectStore(storeName).get(key);
return new Promise<T | undefined>((resolve, reject) => {
request.onsuccess = () => resolve(request.result as T | undefined);
request.onerror = () => reject(request.error);
});
}
private async getAll<T>(storeName: string): Promise<T[]> {
const transaction = this.createTransaction(storeName, 'readonly');
const request = transaction.objectStore(storeName).getAll();
return new Promise<T[]>((resolve, reject) => {
request.onsuccess = () => resolve((request.result as T[]) ?? []);
request.onerror = () => reject(request.error);
});
}
private async getAllFromIndex<T>(
storeName: string,
indexName: string,
query: IDBValidKey | IDBKeyRange
): Promise<T[]> {
const transaction = this.createTransaction(storeName, 'readonly');
const request = transaction.objectStore(storeName)
.index(indexName)
.getAll(query);
return new Promise<T[]>((resolve, reject) => {
request.onsuccess = () => resolve((request.result as T[]) ?? []);
request.onerror = () => reject(request.error);
});
}
private async foldMessagesForRoom<T>(
roomId: string,
visit: (state: T, message: Message) => void,
initialState: T
): Promise<T> {
const transaction = this.createTransaction(STORE_MESSAGES, 'readonly');
const request = transaction.objectStore(STORE_MESSAGES)
.index('roomId')
.openCursor(IDBKeyRange.only(roomId));
return new Promise<T>((resolve, reject) => {
const state = initialState;
request.onsuccess = () => {
const cursor = request.result;
if (!cursor) {
resolve(state);
return;
}
visit(state, cursor.value as Message);
cursor.continue();
};
request.onerror = () => reject(request.error);
transaction.onabort = () => reject(transaction.error);
});
}
private async deleteRecord(storeName: string, key: IDBValidKey): Promise<void> {
const transaction = this.createTransaction(storeName, 'readwrite');
transaction.objectStore(storeName).delete(key);
await this.awaitTransaction(transaction);
}
private async hydrateMessages(messages: Message[]): Promise<Message[]> {
if (messages.length === 0) {
return [];
}
const reactionsByMessageId = await this.loadReactionsForMessages(messages.map((message) => message.id));
return messages.map((message) => this.normaliseMessage({
...message,
reactions: reactionsByMessageId.get(message.id) ?? message.reactions ?? []
}));
}
private async loadReactionsForMessages(messageIds: readonly string[]): Promise<Map<string, Reaction[]>> {
const messageIdSet = new Set(messageIds.filter((messageId) => messageId.trim().length > 0));
const reactionsByMessageId = new Map<string, Reaction[]>();
if (messageIdSet.size === 0) {
return reactionsByMessageId;
}
const allReactions = await this.getAll<Reaction>(STORE_REACTIONS);
for (const reaction of allReactions) {
if (!messageIdSet.has(reaction.messageId)) {
continue;
}
const reactions = reactionsByMessageId.get(reaction.messageId) ?? [];
reactions.push(reaction);
reactionsByMessageId.set(reaction.messageId, reactions);
}
for (const reactions of reactionsByMessageId.values()) {
reactions.sort((first, second) => first.timestamp - second.timestamp);
}
return reactionsByMessageId;
}
private normaliseMessage(message: Message): Message {
if (message.content === DELETED_MESSAGE_CONTENT) {
return { ...message,
reactions: [] };
}
return message;
}
}