Consolidate IndexedDB into a single 'ditto' database with multiple stores

Replace three separate per-hostname databases (nostr-nip05-cache-*,
nostr-profile-cache-*, nostr-dm-store-*) with one shared 'ditto'
database containing three object stores: nip05, profiles, messages.

A single db.ts module manages the connection and upgrade logic, and
caches the promise so only one connection is created per page lifetime.
This commit is contained in:
Alex Gleason
2026-03-20 21:59:48 -05:00
parent d9e943d03c
commit 23e55ffff7
4 changed files with 66 additions and 88 deletions
+46
View File
@@ -0,0 +1,46 @@
import { openDB, type IDBPDatabase } from 'idb';
// ============================================================================
// Unified IndexedDB database for Ditto.
//
// All persistent client-side data lives in a single "ditto" database with
// one object store per data domain. Callers should import `openDatabase()`
// rather than managing their own `openDB` calls.
// ============================================================================
const DB_NAME = 'ditto';
const DB_VERSION = 1;
/** Store names — keep in sync with the `upgrade` callback below. */
export const STORE = {
NIP05: 'nip05',
PROFILES: 'profiles',
MESSAGES: 'messages',
} as const;
let dbPromise: Promise<IDBPDatabase> | null = null;
/**
* Open (or reuse) the shared Ditto database.
*
* The returned promise is cached so only one connection is created per
* page lifetime, regardless of how many callers import this function.
*/
export function openDatabase(): Promise<IDBPDatabase> {
if (!dbPromise) {
dbPromise = openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE.NIP05)) {
db.createObjectStore(STORE.NIP05);
}
if (!db.objectStoreNames.contains(STORE.PROFILES)) {
db.createObjectStore(STORE.PROFILES);
}
if (!db.objectStoreNames.contains(STORE.MESSAGES)) {
db.createObjectStore(STORE.MESSAGES);
}
},
});
}
return dbPromise;
}
+9 -32
View File
@@ -1,19 +1,10 @@
import { openDB, type IDBPDatabase } from 'idb';
import type { NostrEvent } from '@nostrify/nostrify';
// ============================================================================
// IndexedDB Schema
// ============================================================================
import { openDatabase, STORE } from '@/lib/db';
// Use domain-based naming to avoid conflicts between apps on same domain
const getDBName = () => {
// Use hostname for unique DB per app (e.g., 'nostr-dm-store-localhost', 'nostr-dm-store-myapp.com')
const hostname = typeof window !== 'undefined' ? window.location.hostname : 'default';
return `nostr-dm-store-${hostname}`;
};
const DB_NAME = getDBName();
const DB_VERSION = 1;
const STORE_NAME = 'messages';
// ============================================================================
// DM Message IndexedDB Store
// ============================================================================
interface StoredParticipant {
messages: NostrEvent[];
@@ -34,20 +25,6 @@ export interface MessageStore {
// Database Operations
// ============================================================================
/**
* Open the IndexedDB database
*/
async function openDatabase(): Promise<IDBPDatabase> {
return openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
// Create the messages store if it doesn't exist
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
},
});
}
/**
* Write messages to IndexedDB for a specific user
* Messages are stored in their original encrypted form (kind 4 or kind 13)
@@ -61,9 +38,9 @@ export async function writeMessagesToDB(
// Store messages in their original encrypted form (no NIP-44 wrapper needed)
// Each message content is already encrypted by the sender
await db.put(STORE_NAME, messageStore, userPubkey);
await db.put(STORE.MESSAGES, messageStore, userPubkey);
} catch (error) {
console.error('[MessageStore] Error writing to IndexedDB:', error);
console.error('[MessageStore] Error writing to IndexedDB:', error);
throw error;
}
}
@@ -77,7 +54,7 @@ export async function readMessagesFromDB(
): Promise<MessageStore | undefined> {
try {
const db = await openDatabase();
const data = await db.get(STORE_NAME, userPubkey);
const data = await db.get(STORE.MESSAGES, userPubkey);
if (!data) {
return undefined;
@@ -96,7 +73,7 @@ export async function readMessagesFromDB(
export async function deleteMessagesFromDB(userPubkey: string): Promise<void> {
try {
const db = await openDatabase();
await db.delete(STORE_NAME, userPubkey);
await db.delete(STORE.MESSAGES, userPubkey);
} catch (error) {
console.error('[MessageStore] Error deleting from IndexedDB:', error);
throw error;
@@ -109,7 +86,7 @@ export async function deleteMessagesFromDB(userPubkey: string): Promise<void> {
export async function clearAllMessages(): Promise<void> {
try {
const db = await openDatabase();
await db.clear(STORE_NAME);
await db.clear(STORE.MESSAGES);
} catch (error) {
console.error('[MessageStore] Error clearing IndexedDB:', error);
throw error;
+5 -28
View File
@@ -1,4 +1,4 @@
import { openDB, type IDBPDatabase } from 'idb';
import { openDatabase, STORE } from '@/lib/db';
// ============================================================================
// NIP-05 IndexedDB Cache
@@ -9,15 +9,6 @@ import { openDB, type IDBPDatabase } from 'idb';
// to re-check.
// ============================================================================
const getDBName = () => {
const hostname = typeof window !== 'undefined' ? window.location.hostname : 'default';
return `nostr-nip05-cache-${hostname}`;
};
const DB_NAME = getDBName();
const DB_VERSION = 1;
const STORE_NAME = 'nip05';
export interface Nip05CacheEntry {
/** The NIP-05 identifier (e.g. "user@domain.com") */
identifier: string;
@@ -44,7 +35,7 @@ export function hydrateNip05Cache(): Promise<void> {
hydratePromise = (async () => {
try {
const db = await openDatabase();
const entries: Nip05CacheEntry[] = await db.getAll(STORE_NAME);
const entries: Nip05CacheEntry[] = await db.getAll(STORE.NIP05);
for (const entry of entries) {
memoryCache.set(entry.identifier, entry);
}
@@ -78,7 +69,7 @@ export async function setNip05Cached(identifier: string, pubkey: string): Promis
try {
const db = await openDatabase();
await db.put(STORE_NAME, entry, identifier);
await db.put(STORE.NIP05, entry, identifier);
} catch {
// Write failure is non-critical — the in-memory cache still works.
}
@@ -93,7 +84,7 @@ export async function deleteNip05Cached(identifier: string): Promise<void> {
try {
const db = await openDatabase();
await db.delete(STORE_NAME, identifier);
await db.delete(STORE.NIP05, identifier);
} catch {
// Non-critical.
}
@@ -105,22 +96,8 @@ export async function clearNip05Cache(): Promise<void> {
try {
const db = await openDatabase();
await db.clear(STORE_NAME);
await db.clear(STORE.NIP05);
} catch {
// Non-critical.
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
async function openDatabase(): Promise<IDBPDatabase> {
return openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
},
});
}
+6 -28
View File
@@ -1,6 +1,7 @@
import { openDB, type IDBPDatabase } from 'idb';
import type { NostrEvent } from '@nostrify/nostrify';
import { openDatabase, STORE } from '@/lib/db';
// ============================================================================
// Kind 0 Profile IndexedDB Cache
//
@@ -10,15 +11,6 @@ import type { NostrEvent } from '@nostrify/nostrify';
// caller can decide when to re-check.
// ============================================================================
const getDBName = () => {
const hostname = typeof window !== 'undefined' ? window.location.hostname : 'default';
return `nostr-profile-cache-${hostname}`;
};
const DB_NAME = getDBName();
const DB_VERSION = 1;
const STORE_NAME = 'profiles';
export interface ProfileCacheEntry {
/** Hex pubkey of the profile author */
pubkey: string;
@@ -45,7 +37,7 @@ export function hydrateProfileCache(): Promise<void> {
hydratePromise = (async () => {
try {
const db = await openDatabase();
const entries: ProfileCacheEntry[] = await db.getAll(STORE_NAME);
const entries: ProfileCacheEntry[] = await db.getAll(STORE.PROFILES);
for (const entry of entries) {
memoryCache.set(entry.pubkey, entry);
}
@@ -86,7 +78,7 @@ export async function setProfileCached(event: NostrEvent): Promise<void> {
try {
const db = await openDatabase();
await db.put(STORE_NAME, entry, event.pubkey);
await db.put(STORE.PROFILES, entry, event.pubkey);
} catch {
// Write failure is non-critical — the in-memory cache still works.
}
@@ -98,7 +90,7 @@ export async function deleteProfileCached(pubkey: string): Promise<void> {
try {
const db = await openDatabase();
await db.delete(STORE_NAME, pubkey);
await db.delete(STORE.PROFILES, pubkey);
} catch {
// Non-critical.
}
@@ -110,22 +102,8 @@ export async function clearProfileCache(): Promise<void> {
try {
const db = await openDatabase();
await db.clear(STORE_NAME);
await db.clear(STORE.PROFILES);
} catch {
// Non-critical.
}
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
async function openDatabase(): Promise<IDBPDatabase> {
return openDB(DB_NAME, DB_VERSION, {
upgrade(db) {
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME);
}
},
});
}