OpChan/packages/core/dist/database/LocalDatabase.js
2025-09-11 14:29:55 +05:30

453 lines
16 KiB
JavaScript

import { MessageType, } from '../types/waku';
import { MessageValidator } from '../utils/MessageValidator';
import { EVerificationStatus } from '../types/identity';
import { openLocalDB, STORE } from '../database/schema';
/**
* Minimal in-memory LocalDatabase
* Mirrors CacheService message handling to enable incremental migration.
*/
export class LocalDatabase {
constructor() {
this.processedMessageIds = new Set();
this.db = null;
this._isSyncing = false;
this._lastSync = null;
this.pendingIds = new Set();
this.pendingListeners = new Set();
this.cache = {
cells: {},
posts: {},
comments: {},
votes: {},
moderations: {},
userIdentities: {},
bookmarks: {},
};
this.validator = new MessageValidator();
}
/**
* Open IndexedDB and hydrate in-memory cache.
*/
async open() {
this.db = await openLocalDB();
await this.hydrateFromIndexedDB();
await this.hydratePendingFromMeta();
}
/**
* Apply a message into the LocalDatabase.
* Returns true if the message was newly processed and stored.
*/
async applyMessage(message) {
if (!(await this.validator.isValidMessage(message))) {
const partialMsg = message;
console.warn('LocalDatabase: Rejecting invalid message', {
messageId: partialMsg?.id,
messageType: partialMsg?.type,
hasSignature: !!partialMsg?.signature,
hasBrowserPubKey: !!partialMsg?.browserPubKey,
});
return false;
}
const validMessage = message;
const messageKey = `${validMessage.type}:${validMessage.id}:${validMessage.timestamp}`;
if (this.processedMessageIds.has(messageKey)) {
return false;
}
this.processedMessageIds.add(messageKey);
this.storeMessage(validMessage);
return true;
}
/**
* Temporary alias to ease migration from CacheService.updateCache
*/
async updateCache(message) {
return this.applyMessage(message);
}
clear() {
this.processedMessageIds.clear();
this.cache.cells = {};
this.cache.posts = {};
this.cache.comments = {};
this.cache.votes = {};
this.cache.moderations = {};
this.cache.userIdentities = {};
this.cache.bookmarks = {};
}
storeMessage(message) {
switch (message.type) {
case MessageType.CELL:
if (!this.cache.cells[message.id] ||
this.cache.cells[message.id]?.timestamp !== message.timestamp) {
this.cache.cells[message.id] = message;
this.put(STORE.CELLS, message);
}
break;
case MessageType.POST:
if (!this.cache.posts[message.id] ||
this.cache.posts[message.id]?.timestamp !== message.timestamp) {
this.cache.posts[message.id] = message;
this.put(STORE.POSTS, message);
}
break;
case MessageType.COMMENT:
if (!this.cache.comments[message.id] ||
this.cache.comments[message.id]?.timestamp !== message.timestamp) {
this.cache.comments[message.id] = message;
this.put(STORE.COMMENTS, message);
}
break;
case MessageType.VOTE: {
const voteKey = `${message.targetId}:${message.author}`;
if (!this.cache.votes[voteKey] ||
this.cache.votes[voteKey]?.timestamp !== message.timestamp) {
this.cache.votes[voteKey] = message;
this.put(STORE.VOTES, { key: voteKey, ...message });
}
break;
}
case MessageType.MODERATE: {
const modMsg = message;
if (!this.cache.moderations[modMsg.targetId] ||
this.cache.moderations[modMsg.targetId]?.timestamp !==
modMsg.timestamp) {
this.cache.moderations[modMsg.targetId] = modMsg;
this.put(STORE.MODERATIONS, modMsg);
}
break;
}
case MessageType.USER_PROFILE_UPDATE: {
const profileMsg = message;
const { author, callSign, displayPreference, timestamp } = profileMsg;
const existing = this.cache.userIdentities[author];
if (!existing || timestamp > existing.lastUpdated) {
const nextRecord = {
ensName: existing?.ensName,
ordinalDetails: existing?.ordinalDetails,
callSign: callSign !== undefined ? callSign : existing?.callSign,
displayPreference,
lastUpdated: timestamp,
verificationStatus: existing?.verificationStatus ??
EVerificationStatus.WALLET_UNCONNECTED,
};
this.cache.userIdentities[author] = nextRecord;
// Persist with address keyPath
this.put(STORE.USER_IDENTITIES, {
address: author,
...nextRecord,
});
}
break;
}
default:
console.warn('LocalDatabase: Received message with unknown type');
break;
}
// Update last sync time using local receipt time for accurate UI
this.updateLastSync(Date.now());
}
/**
* Hydrate cache from IndexedDB on warm start
*/
async hydrateFromIndexedDB() {
if (!this.db)
return;
const [cells, posts, comments, votes, moderations, identities, bookmarks] = await Promise.all([
this.getAllFromStore(STORE.CELLS),
this.getAllFromStore(STORE.POSTS),
this.getAllFromStore(STORE.COMMENTS),
this.getAllFromStore(STORE.VOTES),
this.getAllFromStore(STORE.MODERATIONS),
this.getAllFromStore(STORE.USER_IDENTITIES),
this.getAllFromStore(STORE.BOOKMARKS),
]);
this.cache.cells = Object.fromEntries(cells.map((c) => [c.id, c]));
this.cache.posts = Object.fromEntries(posts.map((p) => [p.id, p]));
this.cache.comments = Object.fromEntries(comments.map((cm) => [cm.id, cm]));
this.cache.votes = Object.fromEntries(votes.map((v) => {
const { key, ...rest } = v;
const vote = rest;
return [key, vote];
}));
this.cache.moderations = Object.fromEntries(moderations.map((m) => [m.targetId, m]));
this.cache.userIdentities = Object.fromEntries(identities.map((u) => {
const { address, ...record } = u;
return [address, record];
}));
this.cache.bookmarks = Object.fromEntries(bookmarks.map((b) => [b.id, b]));
}
async hydratePendingFromMeta() {
if (!this.db)
return;
const meta = await this.getAllFromStore(STORE.META);
meta
.filter((entry) => typeof entry.key === 'string' && entry.key.startsWith('pending:'))
.forEach((entry) => {
const id = entry.key.substring('pending:'.length);
this.pendingIds.add(id);
});
}
getAllFromStore(storeName) {
return new Promise((resolve, reject) => {
if (!this.db)
return resolve([]);
const tx = this.db.transaction(storeName, 'readonly');
const store = tx.objectStore(storeName);
const request = store.getAll();
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
}
put(storeName, value) {
if (!this.db)
return;
const tx = this.db.transaction(storeName, 'readwrite');
const store = tx.objectStore(storeName);
store.put(value);
}
getSyncState() {
return { lastSync: this._lastSync, isSyncing: this._isSyncing };
}
setSyncing(isSyncing) {
this._isSyncing = isSyncing;
}
updateLastSync(timestamp) {
this._lastSync = Math.max(this._lastSync ?? 0, timestamp);
// persist in META store (best-effort)
if (!this.db)
return;
const tx = this.db.transaction(STORE.META, 'readwrite');
const store = tx.objectStore(STORE.META);
store.put({ key: 'lastSync', value: this._lastSync });
}
markPending(id) {
this.pendingIds.add(id);
if (!this.db)
return;
const tx = this.db.transaction(STORE.META, 'readwrite');
const store = tx.objectStore(STORE.META);
store.put({ key: `pending:${id}`, value: true });
this.pendingListeners.forEach((l) => l());
}
clearPending(id) {
this.pendingIds.delete(id);
if (!this.db)
return;
const tx = this.db.transaction(STORE.META, 'readwrite');
const store = tx.objectStore(STORE.META);
store.delete(`pending:${id}`);
this.pendingListeners.forEach((l) => l());
}
isPending(id) {
return this.pendingIds.has(id);
}
onPendingChange(listener) {
this.pendingListeners.add(listener);
return () => this.pendingListeners.delete(listener);
}
// ===== User Authentication Storage =====
/**
* Store user authentication data
*/
async storeUser(user) {
const userData = {
key: 'current',
value: user,
timestamp: Date.now(),
};
this.put(STORE.USER_AUTH, userData);
}
/**
* Load user authentication data
*/
async loadUser() {
if (!this.db)
return null;
return new Promise((resolve, reject) => {
const tx = this.db.transaction(STORE.USER_AUTH, 'readonly');
const store = tx.objectStore(STORE.USER_AUTH);
const request = store.get('current');
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const result = request.result;
if (!result) {
resolve(null);
return;
}
const user = result.value;
const lastChecked = user.lastChecked || 0;
const expiryTime = 24 * 60 * 60 * 1000; // 24 hours
if (Date.now() - lastChecked < expiryTime) {
resolve(user);
}
else {
// User data expired, clear it
this.clearUser();
resolve(null);
}
};
});
}
/**
* Clear user authentication data
*/
async clearUser() {
if (!this.db)
return;
const tx = this.db.transaction(STORE.USER_AUTH, 'readwrite');
const store = tx.objectStore(STORE.USER_AUTH);
store.delete('current');
}
// ===== Delegation Storage =====
/**
* Store delegation information
*/
async storeDelegation(delegation) {
const delegationData = {
key: 'current',
value: delegation,
timestamp: Date.now(),
};
this.put(STORE.DELEGATION, delegationData);
}
/**
* Load delegation information
*/
async loadDelegation() {
if (!this.db)
return null;
return new Promise((resolve, reject) => {
const tx = this.db.transaction(STORE.DELEGATION, 'readonly');
const store = tx.objectStore(STORE.DELEGATION);
const request = store.get('current');
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const result = request.result;
resolve(result?.value || null);
};
});
}
/**
* Clear delegation information
*/
async clearDelegation() {
if (!this.db)
return;
const tx = this.db.transaction(STORE.DELEGATION, 'readwrite');
const store = tx.objectStore(STORE.DELEGATION);
store.delete('current');
}
// ===== UI State Storage =====
/**
* Store UI state value
*/
async storeUIState(key, value) {
const stateData = {
key,
value,
timestamp: Date.now(),
};
this.put(STORE.UI_STATE, stateData);
}
/**
* Load UI state value
*/
async loadUIState(key) {
if (!this.db)
return null;
return new Promise((resolve, reject) => {
const tx = this.db.transaction(STORE.UI_STATE, 'readonly');
const store = tx.objectStore(STORE.UI_STATE);
const request = store.get(key);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const result = request.result;
resolve(result?.value || null);
};
});
}
/**
* Clear UI state value
*/
async clearUIState(key) {
if (!this.db)
return;
const tx = this.db.transaction(STORE.UI_STATE, 'readwrite');
const store = tx.objectStore(STORE.UI_STATE);
store.delete(key);
}
// ===== Bookmark Storage =====
/**
* Add a bookmark
*/
async addBookmark(bookmark) {
this.cache.bookmarks[bookmark.id] = bookmark;
this.put(STORE.BOOKMARKS, bookmark);
}
/**
* Remove a bookmark
*/
async removeBookmark(bookmarkId) {
delete this.cache.bookmarks[bookmarkId];
if (!this.db)
return;
const tx = this.db.transaction(STORE.BOOKMARKS, 'readwrite');
const store = tx.objectStore(STORE.BOOKMARKS);
store.delete(bookmarkId);
}
/**
* Get all bookmarks for a user
*/
async getUserBookmarks(userId) {
if (!this.db)
return [];
return new Promise((resolve, reject) => {
const tx = this.db.transaction(STORE.BOOKMARKS, 'readonly');
const store = tx.objectStore(STORE.BOOKMARKS);
const index = store.index('by_userId');
const request = index.getAll(userId);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
}
/**
* Get bookmarks by type for a user
*/
async getUserBookmarksByType(userId, type) {
if (!this.db)
return [];
return new Promise((resolve, reject) => {
const tx = this.db.transaction(STORE.BOOKMARKS, 'readonly');
const store = tx.objectStore(STORE.BOOKMARKS);
const index = store.index('by_userId');
const request = index.getAll(userId);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const bookmarks = request.result;
const filtered = bookmarks.filter((b) => b.type === type);
resolve(filtered);
};
});
}
/**
* Check if an item is bookmarked by a user
*/
isBookmarked(userId, type, targetId) {
const bookmarkId = `${type}:${targetId}`;
const bookmark = this.cache.bookmarks[bookmarkId];
return bookmark?.userId === userId;
}
/**
* Get bookmark by ID
*/
getBookmark(bookmarkId) {
return this.cache.bookmarks[bookmarkId];
}
/**
* Get all bookmarks from cache
*/
getAllBookmarks() {
return Object.values(this.cache.bookmarks);
}
}
export const localDatabase = new LocalDatabase();
//# sourceMappingURL=LocalDatabase.js.map