mirror of
https://github.com/logos-messaging/OpChan.git
synced 2026-01-05 06:13:11 +00:00
453 lines
16 KiB
JavaScript
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
|