dm cache
This commit is contained in:
36
packages/app/src/Cache/DMCache.ts
Normal file
36
packages/app/src/Cache/DMCache.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { RawEvent } from "@snort/nostr";
|
||||
import { db } from "Db";
|
||||
import { dedupe } from "Util";
|
||||
import FeedCache from "./FeedCache";
|
||||
|
||||
class DMCache extends FeedCache<RawEvent> {
|
||||
constructor() {
|
||||
super("DMCache", db.dms);
|
||||
}
|
||||
|
||||
key(of: RawEvent): string {
|
||||
return of.id;
|
||||
}
|
||||
|
||||
override async preload(): Promise<void> {
|
||||
await super.preload();
|
||||
// load all dms to memory
|
||||
await this.buffer([...this.onTable]);
|
||||
}
|
||||
|
||||
newest(): number {
|
||||
let ret = 0;
|
||||
this.cache.forEach(v => (ret = v.created_at > ret ? v.created_at : ret));
|
||||
return ret;
|
||||
}
|
||||
|
||||
allDms(): Array<RawEvent> {
|
||||
return [...this.cache.values()];
|
||||
}
|
||||
|
||||
takeSnapshot(): Array<RawEvent> {
|
||||
return this.allDms();
|
||||
}
|
||||
}
|
||||
|
||||
export const DmCache = new DMCache();
|
162
packages/app/src/Cache/FeedCache.ts
Normal file
162
packages/app/src/Cache/FeedCache.ts
Normal file
@ -0,0 +1,162 @@
|
||||
import { db } from "Db";
|
||||
import { Table } from "dexie";
|
||||
import { unixNowMs, unwrap } from "Util";
|
||||
|
||||
type HookFn = () => void;
|
||||
|
||||
interface HookFilter {
|
||||
key: string;
|
||||
fn: HookFn;
|
||||
}
|
||||
|
||||
export default abstract class FeedCache<TCached> {
|
||||
#name: string;
|
||||
#table: Table<TCached>;
|
||||
#hooks: Array<HookFilter> = [];
|
||||
#snapshot: Readonly<Array<TCached>> = [];
|
||||
#changed = true;
|
||||
protected onTable: Set<string> = new Set();
|
||||
protected cache: Map<string, TCached> = new Map();
|
||||
|
||||
constructor(name: string, table: Table<TCached>) {
|
||||
this.#name = name;
|
||||
this.#table = table;
|
||||
setInterval(() => {
|
||||
console.debug(
|
||||
`[${this.#name}] ${this.cache.size} loaded, ${this.onTable.size} on-disk, ${this.#hooks.length} hooks`
|
||||
);
|
||||
}, 5_000);
|
||||
}
|
||||
|
||||
async preload() {
|
||||
if (db.ready) {
|
||||
const keys = await this.#table.toCollection().primaryKeys();
|
||||
this.onTable = new Set<string>(keys.map(a => a as string));
|
||||
}
|
||||
}
|
||||
|
||||
hook(fn: HookFn, key: string | undefined) {
|
||||
if (!key) {
|
||||
return () => {
|
||||
//noop
|
||||
};
|
||||
}
|
||||
|
||||
this.#hooks.push({
|
||||
key,
|
||||
fn,
|
||||
});
|
||||
return () => {
|
||||
const idx = this.#hooks.findIndex(a => a.fn === fn);
|
||||
if (idx >= 0) {
|
||||
this.#hooks.splice(idx, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
getFromCache(key?: string) {
|
||||
if (key) {
|
||||
return this.cache.get(key);
|
||||
}
|
||||
}
|
||||
|
||||
async get(key?: string) {
|
||||
if (key && !this.cache.has(key) && db.ready) {
|
||||
const cached = await this.#table.get(key);
|
||||
if (cached) {
|
||||
this.cache.set(this.key(cached), cached);
|
||||
this.notifyChange([key]);
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
return key ? this.cache.get(key) : undefined;
|
||||
}
|
||||
|
||||
async bulkGet(keys: Array<string>) {
|
||||
const missing = keys.filter(a => !this.cache.has(a));
|
||||
if (missing.length > 0 && db.ready) {
|
||||
const cached = await this.#table.bulkGet(missing);
|
||||
cached.forEach(a => {
|
||||
if (a) {
|
||||
this.cache.set(this.key(a), a);
|
||||
}
|
||||
});
|
||||
}
|
||||
return keys
|
||||
.map(a => this.cache.get(a))
|
||||
.filter(a => a)
|
||||
.map(a => unwrap(a));
|
||||
}
|
||||
|
||||
async set(obj: TCached) {
|
||||
const k = this.key(obj);
|
||||
this.cache.set(k, obj);
|
||||
if (db.ready) {
|
||||
await this.#table.put(obj);
|
||||
this.onTable.add(k);
|
||||
}
|
||||
this.notifyChange([k]);
|
||||
}
|
||||
|
||||
async bulkSet(obj: Array<TCached>) {
|
||||
if (db.ready) {
|
||||
await this.#table.bulkPut(obj);
|
||||
obj.forEach(a => this.onTable.add(this.key(a)));
|
||||
}
|
||||
obj.forEach(v => this.cache.set(this.key(v), v));
|
||||
this.notifyChange(obj.map(a => this.key(a)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a list of rows from disk cache
|
||||
* @param keys List of ids to load
|
||||
* @returns Keys that do not exist on disk cache
|
||||
*/
|
||||
async buffer(keys: Array<string>): Promise<Array<string>> {
|
||||
const needsBuffer = keys.filter(a => !this.cache.has(a));
|
||||
if (db.ready && needsBuffer.length > 0) {
|
||||
const mapped = needsBuffer.map(a => ({
|
||||
has: this.onTable.has(a),
|
||||
key: a,
|
||||
}));
|
||||
const start = unixNowMs();
|
||||
const fromCache = await this.#table.bulkGet(mapped.filter(a => a.has).map(a => a.key));
|
||||
const fromCacheFiltered = fromCache.filter(a => a !== undefined).map(a => unwrap(a));
|
||||
fromCacheFiltered.forEach(a => {
|
||||
this.cache.set(this.key(a), a);
|
||||
});
|
||||
this.notifyChange(fromCacheFiltered.map(a => this.key(a)));
|
||||
console.debug(
|
||||
`[${this.#name}] Loaded ${fromCacheFiltered.length}/${keys.length} in ${(
|
||||
unixNowMs() - start
|
||||
).toLocaleString()} ms`
|
||||
);
|
||||
return mapped.filter(a => !a.has).map(a => a.key);
|
||||
}
|
||||
|
||||
// no IndexdDB always return all keys
|
||||
return needsBuffer;
|
||||
}
|
||||
|
||||
async clear() {
|
||||
await this.#table.clear();
|
||||
this.cache.clear();
|
||||
this.onTable.clear();
|
||||
}
|
||||
|
||||
snapshot() {
|
||||
if (this.#changed) {
|
||||
this.#snapshot = this.takeSnapshot();
|
||||
this.#changed = false;
|
||||
}
|
||||
return this.#snapshot;
|
||||
}
|
||||
|
||||
protected notifyChange(keys: Array<string>) {
|
||||
this.#changed = true;
|
||||
this.#hooks.filter(a => keys.includes(a.key)).forEach(h => h.fn());
|
||||
}
|
||||
|
||||
abstract key(of: TCached): string;
|
||||
abstract takeSnapshot(): Array<TCached>;
|
||||
}
|
83
packages/app/src/Cache/UserCache.ts
Normal file
83
packages/app/src/Cache/UserCache.ts
Normal file
@ -0,0 +1,83 @@
|
||||
import FeedCache from "Cache/FeedCache";
|
||||
import { db } from "Db";
|
||||
import { LNURL } from "LNURL";
|
||||
import { MetadataCache } from "Cache";
|
||||
|
||||
class UserProfileCache extends FeedCache<MetadataCache> {
|
||||
constructor() {
|
||||
super("UserCache", db.users);
|
||||
}
|
||||
|
||||
key(of: MetadataCache): string {
|
||||
return of.pubkey;
|
||||
}
|
||||
|
||||
async search(q: string): Promise<Array<MetadataCache>> {
|
||||
if (db.ready) {
|
||||
// on-disk cache will always have more data
|
||||
return (
|
||||
await db.users
|
||||
.where("npub")
|
||||
.startsWithIgnoreCase(q)
|
||||
.or("name")
|
||||
.startsWithIgnoreCase(q)
|
||||
.or("display_name")
|
||||
.startsWithIgnoreCase(q)
|
||||
.or("nip05")
|
||||
.startsWithIgnoreCase(q)
|
||||
.toArray()
|
||||
).slice(0, 5);
|
||||
} else {
|
||||
return [...this.cache.values()]
|
||||
.filter(user => {
|
||||
const profile = user as MetadataCache;
|
||||
return (
|
||||
profile.name?.includes(q) ||
|
||||
profile.npub?.includes(q) ||
|
||||
profile.display_name?.includes(q) ||
|
||||
profile.nip05?.includes(q)
|
||||
);
|
||||
})
|
||||
.slice(0, 5);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to update the profile metadata cache with a new version
|
||||
* @param m Profile metadata
|
||||
* @returns
|
||||
*/
|
||||
async update(m: MetadataCache) {
|
||||
const existing = this.getFromCache(m.pubkey);
|
||||
const refresh = existing && existing.created === m.created && existing.loaded < m.loaded;
|
||||
if (!existing || existing.created < m.created || refresh) {
|
||||
// fetch zapper key
|
||||
const lnurl = m.lud16 || m.lud06;
|
||||
if (lnurl) {
|
||||
try {
|
||||
const svc = new LNURL(lnurl);
|
||||
await svc.load();
|
||||
m.zapService = svc.zapperPubkey;
|
||||
} catch {
|
||||
console.debug("Failed to load LNURL for zapper pubkey", lnurl);
|
||||
}
|
||||
// ignored
|
||||
}
|
||||
|
||||
this.cache.set(m.pubkey, m);
|
||||
if (db.ready) {
|
||||
await db.users.put(m);
|
||||
this.onTable.add(m.pubkey);
|
||||
}
|
||||
this.notifyChange([m.pubkey]);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
takeSnapshot(): MetadataCache[] {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export const UserCache = new UserProfileCache();
|
53
packages/app/src/Cache/index.ts
Normal file
53
packages/app/src/Cache/index.ts
Normal file
@ -0,0 +1,53 @@
|
||||
import { HexKey, TaggedRawEvent, UserMetadata } from "@snort/nostr";
|
||||
import { hexToBech32, unixNowMs } from "Util";
|
||||
import { DmCache } from "./DMCache";
|
||||
import { UserCache } from "./UserCache";
|
||||
|
||||
export interface MetadataCache extends UserMetadata {
|
||||
/**
|
||||
* When the object was saved in cache
|
||||
*/
|
||||
loaded: number;
|
||||
|
||||
/**
|
||||
* When the source metadata event was created
|
||||
*/
|
||||
created: number;
|
||||
|
||||
/**
|
||||
* The pubkey of the owner of this metadata
|
||||
*/
|
||||
pubkey: HexKey;
|
||||
|
||||
/**
|
||||
* The bech32 encoded pubkey
|
||||
*/
|
||||
npub: string;
|
||||
|
||||
/**
|
||||
* Pubkey of zapper service
|
||||
*/
|
||||
zapService?: HexKey;
|
||||
}
|
||||
|
||||
export function mapEventToProfile(ev: TaggedRawEvent) {
|
||||
try {
|
||||
const data: UserMetadata = JSON.parse(ev.content);
|
||||
return {
|
||||
pubkey: ev.pubkey,
|
||||
npub: hexToBech32("npub", ev.pubkey),
|
||||
created: ev.created_at,
|
||||
...data,
|
||||
loaded: unixNowMs(),
|
||||
} as MetadataCache;
|
||||
} catch (e) {
|
||||
console.error("Failed to parse JSON", ev, e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function preload() {
|
||||
await UserCache.preload();
|
||||
await DmCache.preload();
|
||||
}
|
||||
|
||||
export { UserCache, DmCache };
|
Reference in New Issue
Block a user