feat: signal server tag

This commit is contained in:
2026-06-05 06:16:02 +02:00
parent 6865147e8f
commit bf4e6891d1
69 changed files with 2808 additions and 1269 deletions
@@ -0,0 +1,121 @@
import {
describe,
expect,
it,
vi
} from 'vitest';
import {
applyBrowserDatabaseSchema,
ensureObjectStoreDuringUpgrade,
ensureStoreIndex
} from './browser-database-schema';
describe('browser-database-schema', () => {
it('reuses the upgrade transaction when an object store already exists', () => {
const existingStore = { indexNames: { contains: () => false } };
const database = {
objectStoreNames: { contains: (name: string) => name === 'messages' },
createObjectStore: vi.fn(),
transaction: vi.fn()
};
const upgradeTransaction = {
objectStore: vi.fn(() => existingStore)
};
const store = ensureObjectStoreDuringUpgrade(
database as unknown as IDBDatabase,
upgradeTransaction as unknown as IDBTransaction,
'messages',
{ keyPath: 'id' }
);
expect(store).toBe(existingStore);
expect(upgradeTransaction.objectStore).toHaveBeenCalledWith('messages');
expect(database.createObjectStore).not.toHaveBeenCalled();
expect(database.transaction).not.toHaveBeenCalled();
});
it('creates missing object stores during upgrade', () => {
const createdStore = { indexNames: { contains: () => false } };
const database = {
objectStoreNames: { contains: () => false },
createObjectStore: vi.fn(() => createdStore),
transaction: vi.fn()
};
const upgradeTransaction = {
objectStore: vi.fn()
};
const store = ensureObjectStoreDuringUpgrade(
database as unknown as IDBDatabase,
upgradeTransaction as unknown as IDBTransaction,
'customEmojis',
{ keyPath: 'id' }
);
expect(store).toBe(createdStore);
expect(database.createObjectStore).toHaveBeenCalledWith('customEmojis', { keyPath: 'id' });
expect(upgradeTransaction.objectStore).not.toHaveBeenCalled();
expect(database.transaction).not.toHaveBeenCalled();
});
it('creates missing indexes on an existing store', () => {
const store = {
indexNames: { contains: () => false },
createIndex: vi.fn()
};
ensureStoreIndex(store as unknown as IDBObjectStore, 'roomId', 'roomId');
expect(store.createIndex).toHaveBeenCalledWith('roomId', 'roomId', { unique: false });
});
it('applies the full schema through the upgrade transaction only', () => {
const stores = new Map<string, { indexNames: { contains: (name: string) => boolean }; createIndex: ReturnType<typeof vi.fn> }>();
const database = {
objectStoreNames: {
contains: (name: string) => stores.has(name)
},
createObjectStore: vi.fn((name: string) => {
const store = {
indexNames: { contains: () => false },
createIndex: vi.fn()
};
stores.set(name, store);
return store;
}),
transaction: vi.fn()
};
const upgradeTransaction = {
objectStore: vi.fn((name: string) => {
const store = stores.get(name);
if (!store) {
throw new Error(`Missing store ${name}`);
}
return store;
})
};
stores.set('messages', {
indexNames: { contains: () => true },
createIndex: vi.fn()
});
stores.set('users', {
indexNames: { contains: () => true },
createIndex: vi.fn()
});
expect(() => applyBrowserDatabaseSchema(
database as unknown as IDBDatabase,
upgradeTransaction as unknown as IDBTransaction
)).not.toThrow();
expect(database.transaction).not.toHaveBeenCalled();
expect(upgradeTransaction.objectStore).toHaveBeenCalledWith('messages');
expect(database.createObjectStore).toHaveBeenCalledWith('customEmojis', { keyPath: 'id' });
});
});
@@ -0,0 +1,67 @@
/** IndexedDB schema version - bump when adding/changing object stores. */
export const BROWSER_DATABASE_VERSION = 3;
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';
export function ensureObjectStoreDuringUpgrade(
database: IDBDatabase,
upgradeTransaction: IDBTransaction,
name: string,
options?: IDBObjectStoreParameters
): IDBObjectStore {
if (database.objectStoreNames.contains(name)) {
return upgradeTransaction.objectStore(name);
}
return database.createObjectStore(name, options);
}
export function ensureStoreIndex(store: IDBObjectStore, name: string, keyPath: string): void {
if (!store.indexNames.contains(name)) {
store.createIndex(name, keyPath, { unique: false });
}
}
export function applyBrowserDatabaseSchema(
database: IDBDatabase,
upgradeTransaction: IDBTransaction
): void {
const messagesStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_MESSAGES, { keyPath: 'id' });
ensureStoreIndex(messagesStore, 'roomId', 'roomId');
ensureStoreIndex(messagesStore, 'timestamp', 'timestamp');
ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_USERS, { keyPath: 'id' });
const roomsStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_ROOMS, { keyPath: 'id' });
ensureStoreIndex(roomsStore, 'timestamp', 'timestamp');
const reactionsStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_REACTIONS, { keyPath: 'id' });
ensureStoreIndex(reactionsStore, 'messageId', 'messageId');
ensureStoreIndex(reactionsStore, 'userId', 'userId');
const bansStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_BANS, { keyPath: 'oderId' });
ensureStoreIndex(bansStore, 'roomId', 'roomId');
ensureStoreIndex(bansStore, 'expiresAt', 'expiresAt');
ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_META, { keyPath: 'id' });
const attachmentsStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_ATTACHMENTS, { keyPath: 'id' });
ensureStoreIndex(attachmentsStore, 'messageId', 'messageId');
const customEmojisStore = ensureObjectStoreDuringUpgrade(database, upgradeTransaction, STORE_CUSTOM_EMOJIS, { keyPath: 'id' });
ensureStoreIndex(customEmojisStore, 'updatedAt', 'updatedAt');
ensureStoreIndex(customEmojisStore, 'creatorUserId', 'creatorUserId');
}
@@ -11,12 +11,11 @@ import {
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';
/** IndexedDB schema version - bump when adding/changing object stores. */
const DATABASE_VERSION = 3;
/** Names of every object store used by the application. */
const STORE_MESSAGES = 'messages';
const STORE_USERS = 'users';
@@ -432,10 +431,26 @@ export class BrowserDatabaseService {
private openDatabase(databaseName: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(databaseName, DATABASE_VERSION);
const request = indexedDB.open(databaseName, BROWSER_DATABASE_VERSION);
request.onerror = () => reject(request.error);
request.onupgradeneeded = () => this.setupSchema(request.result);
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);
});
}
@@ -446,58 +461,6 @@ export class BrowserDatabaseService {
this.activeDatabaseName = null;
}
private setupSchema(database: IDBDatabase): void {
const messagesStore = this.ensureStore(database, STORE_MESSAGES, { keyPath: 'id' });
this.ensureIndex(messagesStore, 'roomId', 'roomId');
this.ensureIndex(messagesStore, 'timestamp', 'timestamp');
this.ensureStore(database, STORE_USERS, { keyPath: 'id' });
const roomsStore = this.ensureStore(database, STORE_ROOMS, { keyPath: 'id' });
this.ensureIndex(roomsStore, 'timestamp', 'timestamp');
const reactionsStore = this.ensureStore(database, STORE_REACTIONS, { keyPath: 'id' });
this.ensureIndex(reactionsStore, 'messageId', 'messageId');
this.ensureIndex(reactionsStore, 'userId', 'userId');
const bansStore = this.ensureStore(database, STORE_BANS, { keyPath: 'oderId' });
this.ensureIndex(bansStore, 'roomId', 'roomId');
this.ensureIndex(bansStore, 'expiresAt', 'expiresAt');
this.ensureStore(database, STORE_META, { keyPath: 'id' });
const attachmentsStore = this.ensureStore(database, STORE_ATTACHMENTS, { keyPath: 'id' });
this.ensureIndex(attachmentsStore, 'messageId', 'messageId');
const customEmojisStore = this.ensureStore(database, STORE_CUSTOM_EMOJIS, { keyPath: 'id' });
this.ensureIndex(customEmojisStore, 'updatedAt', 'updatedAt');
this.ensureIndex(customEmojisStore, 'creatorUserId', 'creatorUserId');
}
private ensureStore(
database: IDBDatabase,
name: string,
options?: IDBObjectStoreParameters
): IDBObjectStore {
if (database.objectStoreNames.contains(name)) {
return (database.transaction(name, 'readonly') as IDBTransaction).objectStore(name);
}
return database.createObjectStore(name, options);
}
private ensureIndex(store: IDBObjectStore, name: string, keyPath: string): void {
if (!store.indexNames.contains(name)) {
store.createIndex(name, keyPath, { unique: false });
}
}
private createTransaction(
storeNames: string | string[],
mode: IDBTransactionMode