feat: Android APP V1 - Experimental Alpha
This commit is contained in:
@@ -0,0 +1,503 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import {
|
||||
DELETED_MESSAGE_CONTENT,
|
||||
type BanEntry,
|
||||
type Message,
|
||||
type Reaction,
|
||||
type Room,
|
||||
type User
|
||||
} from '../../shared-kernel';
|
||||
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
|
||||
import { getStoredCurrentUserId } from '../../core/storage/current-user-storage';
|
||||
import {
|
||||
attachmentToValues,
|
||||
banToValues,
|
||||
customEmojiToValues,
|
||||
messageToRow,
|
||||
reactionToValues,
|
||||
roomToRow,
|
||||
rowToAttachment,
|
||||
rowToCustomEmoji,
|
||||
rowToMessage,
|
||||
rowToRoom,
|
||||
rowToUser,
|
||||
userToRow,
|
||||
type MessageRow,
|
||||
type RoomRow,
|
||||
type UserRow
|
||||
} from '../mobile/logic/mobile-sqlite-row-mapper.rules';
|
||||
import { MobileSqliteConnectionService } from '../mobile/services/mobile-sqlite-connection.service';
|
||||
import type { RoomMessageStats } from './database.service';
|
||||
|
||||
/**
|
||||
* SQLite-backed database service for Capacitor native shells.
|
||||
*
|
||||
* Mirrors the {@link BrowserDatabaseService} API using `@capacitor-community/sqlite`.
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class CapacitorDatabaseService {
|
||||
private readonly connection = inject(MobileSqliteConnectionService);
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.connection.initialize();
|
||||
}
|
||||
|
||||
async saveMessage(message: Message): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
const row = messageToRow(message);
|
||||
|
||||
await store.run(
|
||||
`INSERT OR REPLACE INTO messages (
|
||||
id, roomId, ownerUserId, channelId, senderId, senderName, content,
|
||||
timestamp, editedAt, isDeleted, replyToId, linkMetadata, kind, systemEvent
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
row.id,
|
||||
row.roomId,
|
||||
row.ownerUserId ?? null,
|
||||
row.channelId ?? null,
|
||||
row.senderId,
|
||||
row.senderName,
|
||||
row.content,
|
||||
row.timestamp,
|
||||
row.editedAt ?? null,
|
||||
row.isDeleted,
|
||||
row.replyToId ?? null,
|
||||
row.linkMetadata ?? null,
|
||||
row.kind ?? null,
|
||||
row.systemEvent ?? null
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async getMessages(
|
||||
roomId: string,
|
||||
limit = 100,
|
||||
offset = 0,
|
||||
channelId?: string,
|
||||
beforeTimestamp?: number
|
||||
): Promise<Message[]> {
|
||||
const store = await this.connection.getStore();
|
||||
const rows = await store.query<MessageRow>(`
|
||||
SELECT * FROM messages
|
||||
WHERE roomId = ?
|
||||
${beforeTimestamp !== undefined ? 'AND timestamp < ?' : ''}
|
||||
ORDER BY timestamp ASC
|
||||
`, beforeTimestamp !== undefined ? [roomId, beforeTimestamp] : [roomId]);
|
||||
const scopedRows = channelId
|
||||
? rows.filter((row) => (row.channelId || 'general') === channelId)
|
||||
: rows;
|
||||
const endIndex = Math.max(scopedRows.length - offset, 0);
|
||||
const startIndex = Math.max(endIndex - limit, 0);
|
||||
const slice = scopedRows.slice(startIndex, endIndex);
|
||||
|
||||
return this.hydrateMessages(slice.map((row) => rowToMessage(row)));
|
||||
}
|
||||
|
||||
async getMessagesSince(roomId: string, sinceTimestamp: number): Promise<Message[]> {
|
||||
const store = await this.connection.getStore();
|
||||
const rows = await store.query<MessageRow>(
|
||||
'SELECT * FROM messages WHERE roomId = ? AND timestamp > ? ORDER BY timestamp ASC',
|
||||
[roomId, sinceTimestamp]
|
||||
);
|
||||
|
||||
return this.hydrateMessages(rows.map((row) => rowToMessage(row)));
|
||||
}
|
||||
|
||||
async getRoomMessageStats(roomId: string): Promise<RoomMessageStats> {
|
||||
const store = await this.connection.getStore();
|
||||
const rows = await store.query<{ count: number; lastUpdated: number }>(
|
||||
`SELECT COUNT(*) as count, MAX(COALESCE(editedAt, timestamp, 0)) as lastUpdated
|
||||
FROM messages WHERE roomId = ?`,
|
||||
[roomId]
|
||||
);
|
||||
|
||||
return {
|
||||
count: Number(rows[0]?.count ?? 0),
|
||||
lastUpdated: Number(rows[0]?.lastUpdated ?? 0)
|
||||
};
|
||||
}
|
||||
|
||||
async deleteMessage(messageId: string): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
|
||||
await store.run('DELETE FROM messages WHERE id = ?', [messageId]);
|
||||
}
|
||||
|
||||
async updateMessage(messageId: string, updates: Partial<Message>): Promise<void> {
|
||||
const existing = await this.getMessageById(messageId);
|
||||
|
||||
if (existing) {
|
||||
await this.saveMessage({ ...existing, ...updates });
|
||||
}
|
||||
}
|
||||
|
||||
async getMessageById(messageId: string): Promise<Message | null> {
|
||||
const store = await this.connection.getStore();
|
||||
const rows = await store.query<MessageRow>('SELECT * FROM messages WHERE id = ? LIMIT 1', [messageId]);
|
||||
const row = rows[0];
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messages = await this.hydrateMessages([rowToMessage(row)]);
|
||||
|
||||
return messages[0] ?? null;
|
||||
}
|
||||
|
||||
async clearRoomMessages(roomId: string): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
|
||||
await store.run('DELETE FROM messages WHERE roomId = ?', [roomId]);
|
||||
}
|
||||
|
||||
async saveReaction(reaction: Reaction): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
const existing = await store.query<Reaction>(
|
||||
'SELECT * FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ? LIMIT 1',
|
||||
[
|
||||
reaction.messageId,
|
||||
reaction.userId,
|
||||
reaction.emoji
|
||||
]
|
||||
);
|
||||
|
||||
if (existing.length === 0) {
|
||||
await store.run(
|
||||
'INSERT OR REPLACE INTO reactions (id, messageId, oderId, userId, emoji, timestamp) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
reactionToValues(reaction)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async removeReaction(messageId: string, userId: string, emoji: string): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
|
||||
await store.run(
|
||||
'DELETE FROM reactions WHERE messageId = ? AND userId = ? AND emoji = ?',
|
||||
[
|
||||
messageId,
|
||||
userId,
|
||||
emoji
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async getReactionsForMessage(messageId: string): Promise<Reaction[]> {
|
||||
const store = await this.connection.getStore();
|
||||
|
||||
return store.query<Reaction>(
|
||||
'SELECT * FROM reactions WHERE messageId = ? ORDER BY timestamp ASC',
|
||||
[messageId]
|
||||
);
|
||||
}
|
||||
|
||||
async saveUser(user: User): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
const row = userToRow(user);
|
||||
|
||||
await store.run(
|
||||
`INSERT OR REPLACE INTO users (
|
||||
id, oderId, username, displayName, description, profileUpdatedAt,
|
||||
avatarUrl, avatarHash, avatarMime, avatarUpdatedAt, status, role,
|
||||
joinedAt, peerId, isOnline, isAdmin, isRoomOwner, voiceState,
|
||||
screenShareState, homeSignalServerUrl
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
row.id,
|
||||
row.oderId ?? null,
|
||||
row.username ?? null,
|
||||
row.displayName ?? null,
|
||||
row.description ?? null,
|
||||
row.profileUpdatedAt ?? null,
|
||||
row.avatarUrl ?? null,
|
||||
row.avatarHash ?? null,
|
||||
row.avatarMime ?? null,
|
||||
row.avatarUpdatedAt ?? null,
|
||||
row.status ?? null,
|
||||
row.role ?? null,
|
||||
row.joinedAt ?? null,
|
||||
row.peerId ?? null,
|
||||
row.isOnline,
|
||||
row.isAdmin,
|
||||
row.isRoomOwner,
|
||||
row.voiceState ?? null,
|
||||
row.screenShareState ?? null,
|
||||
row.homeSignalServerUrl ?? null
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async getUser(userId: string): Promise<User | null> {
|
||||
const store = await this.connection.getStore();
|
||||
const rows = await store.query<UserRow>('SELECT * FROM users WHERE id = ? LIMIT 1', [userId]);
|
||||
|
||||
return rows[0] ? rowToUser(rows[0]) : null;
|
||||
}
|
||||
|
||||
async getCurrentUser(): Promise<User | null> {
|
||||
const userId = await this.getCurrentUserId();
|
||||
|
||||
return userId ? this.getUser(userId) : null;
|
||||
}
|
||||
|
||||
async getCurrentUserId(): Promise<string | null> {
|
||||
const store = await this.connection.getStore();
|
||||
const rows = await store.query<{ value: string }>(
|
||||
"SELECT value FROM meta WHERE key = 'currentUserId' LIMIT 1"
|
||||
);
|
||||
|
||||
return rows[0]?.value?.trim() || null;
|
||||
}
|
||||
|
||||
async setCurrentUserId(userId: string): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
|
||||
await store.run(
|
||||
"INSERT OR REPLACE INTO meta (key, value) VALUES ('currentUserId', ?)",
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (getStoredCurrentUserId() !== userId) {
|
||||
await this.connection.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
async getUsersByRoom(_roomId: string): Promise<User[]> {
|
||||
const store = await this.connection.getStore();
|
||||
const rows = await store.query<UserRow>('SELECT * FROM users');
|
||||
|
||||
return rows.map(rowToUser);
|
||||
}
|
||||
|
||||
async updateUser(userId: string, updates: Partial<User>): Promise<void> {
|
||||
const existing = await this.getUser(userId);
|
||||
|
||||
if (existing) {
|
||||
await this.saveUser({ ...existing, ...updates });
|
||||
}
|
||||
}
|
||||
|
||||
async saveRoom(room: Room): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
const row = roomToRow(room);
|
||||
|
||||
await store.run(
|
||||
`INSERT OR REPLACE INTO rooms (
|
||||
id, name, description, topic, hostId, password, hasPassword, isPrivate,
|
||||
createdAt, userCount, maxUsers, icon, iconUpdatedAt, slowModeInterval,
|
||||
sourceId, sourceName, sourceUrl
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
row.id,
|
||||
row.name,
|
||||
row.description ?? null,
|
||||
row.topic ?? null,
|
||||
row.hostId,
|
||||
row.password ?? null,
|
||||
row.hasPassword,
|
||||
row.isPrivate,
|
||||
row.createdAt,
|
||||
row.userCount,
|
||||
row.maxUsers ?? null,
|
||||
row.icon ?? null,
|
||||
row.iconUpdatedAt ?? null,
|
||||
row.slowModeInterval,
|
||||
row.sourceId ?? null,
|
||||
row.sourceName ?? null,
|
||||
row.sourceUrl ?? null
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async getRoom(roomId: string): Promise<Room | null> {
|
||||
const store = await this.connection.getStore();
|
||||
const rows = await store.query<RoomRow>('SELECT * FROM rooms WHERE id = ? LIMIT 1', [roomId]);
|
||||
|
||||
return rows[0] ? rowToRoom(rows[0]) : null;
|
||||
}
|
||||
|
||||
async getAllRooms(): Promise<Room[]> {
|
||||
const store = await this.connection.getStore();
|
||||
const rows = await store.query<RoomRow>('SELECT * FROM rooms ORDER BY createdAt ASC');
|
||||
|
||||
return rows.map(rowToRoom);
|
||||
}
|
||||
|
||||
async deleteRoom(roomId: string): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
|
||||
await store.run('DELETE FROM rooms WHERE id = ?', [roomId]);
|
||||
await this.clearRoomMessages(roomId);
|
||||
}
|
||||
|
||||
async updateRoom(roomId: string, updates: Partial<Room>): Promise<void> {
|
||||
const existing = await this.getRoom(roomId);
|
||||
|
||||
if (existing) {
|
||||
await this.saveRoom({ ...existing, ...updates });
|
||||
}
|
||||
}
|
||||
|
||||
async saveBan(ban: BanEntry): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
|
||||
await store.run(
|
||||
`INSERT OR REPLACE INTO bans (
|
||||
oderId, roomId, userId, bannedBy, displayName, reason, expiresAt, timestamp
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
banToValues(ban)
|
||||
);
|
||||
}
|
||||
|
||||
async removeBan(oderId: string): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
|
||||
await store.run('DELETE FROM bans WHERE oderId = ?', [oderId]);
|
||||
}
|
||||
|
||||
async getBansForRoom(roomId: string): Promise<BanEntry[]> {
|
||||
const store = await this.connection.getStore();
|
||||
const now = Date.now();
|
||||
const rows = await store.query<BanEntry>(
|
||||
'SELECT * FROM bans WHERE roomId = ?',
|
||||
[roomId]
|
||||
);
|
||||
|
||||
return rows.filter((ban) => !ban.expiresAt || ban.expiresAt > now);
|
||||
}
|
||||
|
||||
async isUserBanned(userId: string, roomId: string): Promise<boolean> {
|
||||
const activeBans = await this.getBansForRoom(roomId);
|
||||
|
||||
return activeBans.some((ban) => ban.oderId === userId);
|
||||
}
|
||||
|
||||
async saveAttachment(attachment: ChatAttachmentMeta): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
|
||||
await store.run(
|
||||
`INSERT OR REPLACE INTO attachments (
|
||||
id, messageId, filename, size, mime, isImage, uploaderPeerId, filePath, savedPath
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
attachmentToValues(attachment)
|
||||
);
|
||||
}
|
||||
|
||||
async getAttachmentsForMessage(messageId: string): Promise<ChatAttachmentMeta[]> {
|
||||
const store = await this.connection.getStore();
|
||||
const rows = await store.query<Parameters<typeof rowToAttachment>[0]>(
|
||||
'SELECT * FROM attachments WHERE messageId = ?',
|
||||
[messageId]
|
||||
);
|
||||
|
||||
return rows.map(rowToAttachment);
|
||||
}
|
||||
|
||||
async getAllAttachments(): Promise<ChatAttachmentMeta[]> {
|
||||
const store = await this.connection.getStore();
|
||||
const rows = await store.query<Parameters<typeof rowToAttachment>[0]>('SELECT * FROM attachments');
|
||||
|
||||
return rows.map(rowToAttachment);
|
||||
}
|
||||
|
||||
async saveCustomEmoji(emoji: CustomEmoji): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
|
||||
await store.run(
|
||||
`INSERT OR REPLACE INTO custom_emojis (
|
||||
id, name, creatorUserId, dataUrl, hash, mime, size, createdAt, updatedAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
customEmojiToValues(emoji)
|
||||
);
|
||||
}
|
||||
|
||||
async getCustomEmojis(): Promise<CustomEmoji[]> {
|
||||
const store = await this.connection.getStore();
|
||||
const rows = await store.query<Parameters<typeof rowToCustomEmoji>[0]>('SELECT * FROM custom_emojis');
|
||||
|
||||
return rows.map(rowToCustomEmoji);
|
||||
}
|
||||
|
||||
async deleteCustomEmoji(emojiId: string): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
|
||||
await store.run('DELETE FROM custom_emojis WHERE id = ?', [emojiId]);
|
||||
}
|
||||
|
||||
async deleteAttachmentsForMessage(messageId: string): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
|
||||
await store.run('DELETE FROM attachments WHERE messageId = ?', [messageId]);
|
||||
}
|
||||
|
||||
async clearAllData(): Promise<void> {
|
||||
const store = await this.connection.getStore();
|
||||
const tables = [
|
||||
'messages',
|
||||
'users',
|
||||
'rooms',
|
||||
'reactions',
|
||||
'bans',
|
||||
'attachments',
|
||||
'custom_emojis',
|
||||
'meta',
|
||||
'push_device_tokens'
|
||||
];
|
||||
|
||||
for (const table of tables) {
|
||||
await store.run(`DELETE FROM ${table}`);
|
||||
}
|
||||
}
|
||||
|
||||
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 store = await this.connection.getStore();
|
||||
const allReactions = await store.query<Reaction>('SELECT * FROM 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import {
|
||||
describe,
|
||||
expect,
|
||||
it
|
||||
} from 'vitest';
|
||||
|
||||
import { resolveDatabaseBackend } from './database-backend.rules';
|
||||
|
||||
describe('database-backend.rules', () => {
|
||||
it('routes Electron to the IPC SQLite backend', () => {
|
||||
expect(resolveDatabaseBackend({ isElectron: true, isCapacitor: false })).toBe('electron');
|
||||
});
|
||||
|
||||
it('routes Capacitor native shells to SQLite instead of IndexedDB', () => {
|
||||
expect(resolveDatabaseBackend({ isElectron: false, isCapacitor: true })).toBe('capacitor-sqlite');
|
||||
});
|
||||
|
||||
it('routes plain browser shells to IndexedDB', () => {
|
||||
expect(resolveDatabaseBackend({ isElectron: false, isCapacitor: false })).toBe('browser');
|
||||
});
|
||||
|
||||
it('prefers Electron when both Electron and Capacitor flags are set', () => {
|
||||
expect(resolveDatabaseBackend({ isElectron: true, isCapacitor: true })).toBe('electron');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
export type DatabaseBackendKind = 'electron' | 'capacitor-sqlite' | 'browser';
|
||||
|
||||
/** Selects the persistence backend for the current runtime shell. */
|
||||
export function resolveDatabaseBackend(input: {
|
||||
isElectron: boolean;
|
||||
isCapacitor: boolean;
|
||||
}): DatabaseBackendKind {
|
||||
if (input.isElectron) {
|
||||
return 'electron';
|
||||
}
|
||||
|
||||
if (input.isCapacitor) {
|
||||
return 'capacitor-sqlite';
|
||||
}
|
||||
|
||||
return 'browser';
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
|
||||
import { PlatformService } from '../../core/platform';
|
||||
import { BrowserDatabaseService } from './browser-database.service';
|
||||
import { CapacitorDatabaseService } from './capacitor-database.service';
|
||||
import { DatabaseService } from './database.service';
|
||||
import { ElectronDatabaseService } from './electron-database.service';
|
||||
|
||||
@@ -20,6 +21,10 @@ describe('DatabaseService', () => {
|
||||
getBansForRoom: ReturnType<typeof vi.fn>;
|
||||
initialize: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let capacitorDatabase: {
|
||||
getBansForRoom: ReturnType<typeof vi.fn>;
|
||||
initialize: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let electronDatabase: {
|
||||
getBansForRoom: ReturnType<typeof vi.fn>;
|
||||
initialize: ReturnType<typeof vi.fn>;
|
||||
@@ -30,18 +35,23 @@ describe('DatabaseService', () => {
|
||||
getBansForRoom: vi.fn(() => Promise.resolve([])),
|
||||
initialize: vi.fn(() => Promise.resolve())
|
||||
};
|
||||
capacitorDatabase = {
|
||||
getBansForRoom: vi.fn(() => Promise.resolve([])),
|
||||
initialize: vi.fn(() => Promise.resolve())
|
||||
};
|
||||
electronDatabase = {
|
||||
getBansForRoom: vi.fn(() => Promise.resolve([])),
|
||||
initialize: vi.fn(() => Promise.resolve())
|
||||
};
|
||||
});
|
||||
|
||||
function createService(): DatabaseService {
|
||||
function createService(platform: Pick<PlatformService, 'isBrowser' | 'isElectron' | 'isCapacitor'>): DatabaseService {
|
||||
const injector = Injector.create({
|
||||
providers: [
|
||||
DatabaseService,
|
||||
{ provide: PlatformService, useValue: { isBrowser: true, isElectron: false } },
|
||||
{ provide: PlatformService, useValue: platform },
|
||||
{ provide: BrowserDatabaseService, useValue: browserDatabase },
|
||||
{ provide: CapacitorDatabaseService, useValue: capacitorDatabase },
|
||||
{ provide: ElectronDatabaseService, useValue: electronDatabase }
|
||||
]
|
||||
});
|
||||
@@ -49,13 +59,35 @@ describe('DatabaseService', () => {
|
||||
return runInInjectionContext(injector, () => injector.get(DatabaseService));
|
||||
}
|
||||
|
||||
it('initializes the selected backend before the first delegated read', async () => {
|
||||
const service = createService();
|
||||
it('initializes the browser backend before the first delegated read', async () => {
|
||||
const service = createService({ isBrowser: true, isElectron: false, isCapacitor: false });
|
||||
|
||||
await service.getBansForRoom('room-1');
|
||||
|
||||
expect(browserDatabase.initialize).toHaveBeenCalledTimes(1);
|
||||
expect(browserDatabase.getBansForRoom).toHaveBeenCalledWith('room-1');
|
||||
expect(capacitorDatabase.initialize).not.toHaveBeenCalled();
|
||||
expect(service.isReady()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('routes Capacitor shells to native SQLite instead of IndexedDB', async () => {
|
||||
const service = createService({ isBrowser: false, isElectron: false, isCapacitor: true });
|
||||
|
||||
await service.getBansForRoom('room-1');
|
||||
|
||||
expect(capacitorDatabase.initialize).toHaveBeenCalledTimes(1);
|
||||
expect(capacitorDatabase.getBansForRoom).toHaveBeenCalledWith('room-1');
|
||||
expect(browserDatabase.initialize).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('routes Electron shells to the IPC SQLite backend', async () => {
|
||||
const service = createService({ isBrowser: false, isElectron: true, isCapacitor: false });
|
||||
|
||||
await service.getBansForRoom('room-1');
|
||||
|
||||
expect(electronDatabase.initialize).toHaveBeenCalledTimes(1);
|
||||
expect(electronDatabase.getBansForRoom).toHaveBeenCalledWith('room-1');
|
||||
expect(browserDatabase.initialize).not.toHaveBeenCalled();
|
||||
expect(capacitorDatabase.initialize).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
import type { ChatAttachmentMeta, CustomEmoji } from '../../shared-kernel';
|
||||
import { PlatformService } from '../../core/platform';
|
||||
import { BrowserDatabaseService } from './browser-database.service';
|
||||
import { CapacitorDatabaseService } from './capacitor-database.service';
|
||||
import { resolveDatabaseBackend } from './database-backend.rules';
|
||||
import { ElectronDatabaseService } from './electron-database.service';
|
||||
|
||||
export interface RoomMessageStats {
|
||||
@@ -26,6 +28,7 @@ export interface RoomMessageStats {
|
||||
* storage backend based on the runtime platform.
|
||||
*
|
||||
* - Electron -> SQLite via {@link ElectronDatabaseService} (IPC to main process).
|
||||
* - Capacitor -> native SQLite via {@link CapacitorDatabaseService}.
|
||||
* - Browser -> IndexedDB via {@link BrowserDatabaseService}.
|
||||
*
|
||||
* All consumers inject `DatabaseService`; the underlying storage engine
|
||||
@@ -35,6 +38,7 @@ export interface RoomMessageStats {
|
||||
export class DatabaseService {
|
||||
private readonly platform = inject(PlatformService);
|
||||
private readonly browserDb = inject(BrowserDatabaseService);
|
||||
private readonly capacitorDb = inject(CapacitorDatabaseService);
|
||||
private readonly electronDb = inject(ElectronDatabaseService);
|
||||
private initializationPromise: Promise<void> | null = null;
|
||||
|
||||
@@ -43,7 +47,20 @@ export class DatabaseService {
|
||||
|
||||
/** The active storage backend for the current platform. */
|
||||
private get backend() {
|
||||
return this.platform.isBrowser ? this.browserDb : this.electronDb;
|
||||
const backendKind = resolveDatabaseBackend({
|
||||
isElectron: this.platform.isElectron,
|
||||
isCapacitor: this.platform.isCapacitor
|
||||
});
|
||||
|
||||
if (backendKind === 'electron') {
|
||||
return this.electronDb;
|
||||
}
|
||||
|
||||
if (backendKind === 'capacitor-sqlite') {
|
||||
return this.capacitorDb;
|
||||
}
|
||||
|
||||
return this.browserDb;
|
||||
}
|
||||
|
||||
/** Initialise the platform-specific database. */
|
||||
|
||||
Reference in New Issue
Block a user