@nostr-dev-kit/ndk-mobile
Version:
NDK Mobile
587 lines (558 loc) ⢠21.1 kB
JavaScript
"use strict";
import { NDKEvent, NDKKind, deserialize, matchFilter, profileFromEvent } from "@nostr-dev-kit/ndk";
import * as SQLite from "expo-sqlite";
import * as Mint from "../../mint/mint-methods.js";
import { getAllProfilesSync } from "./get-all-profiles.js";
import { migrations } from "./migrations.js";
import { getAllNutzapStates } from "./nutzap-state-get.js";
import { prepareNutzapStateUpdate } from "./nutzap-state-set.js";
/**
* This is an entry with a loaded event that we can listen on for publication event to update
* our internal state.
*/
/**
* This represents an entry in the database for an unpublished event.
*/
function filterForCache(subscription) {
if (!subscription.cacheUnconstrainFilter) return subscription.filters;
const filterCopy = subscription.filters.map(filter => ({
...filter
}));
// remove the keys that are in the cacheUnconstrainFilter
return filterCopy.filter(filter => {
for (const key of subscription.cacheUnconstrainFilter) {
delete filter[key];
}
return Object.keys(filter).length > 0;
});
}
export class NDKCacheAdapterSqlite {
// Added to hold NDK instance
locking = false; // Re-add locking property required by interface
unpublishedEventIds = new Set();
/**
* This tracks the events we have written to the database along with their timestamp.
*/
knownEventTimestamps = new Map();
writeBuffer = [];
bufferFlushTimeout = 100; // milliseconds
bufferFlushTimer = null;
// Mint management methods
getMintInfo = Mint.getMintInfo.bind(this);
getMintInfoRecord = Mint.getMintInfoRecord.bind(this);
getAllMintInfo = Mint.getAllMintInfo.bind(this);
setMintInfo = Mint.setMintInfo.bind(this);
getMintKeys = Mint.getMintKeys.bind(this);
getMintKeyset = Mint.getMintKeyset.bind(this);
getMintKeysetRecord = Mint.getMintKeysetRecord.bind(this);
getAllMintKeysets = Mint.getAllMintKeysets.bind(this);
setMintKeys = Mint.setMintKeys.bind(this);
// Profile methods
getAllProfilesSync = getAllProfilesSync.bind(this);
// Nutzap state methods
getAllNutzapStates = getAllNutzapStates.bind(this); // Bind the imported function
constructor(dbName, ndkInstance) {
// Optionally accept NDK instance
this.dbName = dbName ?? "ndk-cache";
this.db = SQLite.openDatabaseSync(this.dbName);
this.ndk = ndkInstance; // Assign NDK instance
}
/**
* Initialize the cache adapter.
*
* This should be called before using it.
*/
initialize() {
const startTime = performance.now();
let {
user_version: schemaVersion
} = this.db.getFirstSync("PRAGMA user_version;");
if (!schemaVersion) {
schemaVersion = 0;
// set the schema version
this.db.execSync(`PRAGMA user_version = ${schemaVersion};`);
this.db.execSync("PRAGMA journal_mode = WAL;");
}
if (!schemaVersion || Number(schemaVersion) < migrations.length) {
this.db.withTransactionSync(() => {
for (let i = Number(schemaVersion); i < migrations.length; i++) {
try {
// Assuming migrations[i].up can run synchronously within a sync transaction
// If this causes issues, the migration functions themselves might need adjustment.
migrations[i].up(this.db);
} catch (e) {
console.error("error running migration", e);
throw e; // Re-throw to abort transaction
}
this.db.execSync(`PRAGMA user_version = ${i + 1};`);
}
// set the schema version
this.db.execSync(`PRAGMA user_version = ${migrations.length};`);
});
}
try {
// load all the event timestamps
const events = this.db.getAllSync("SELECT id, created_at FROM events");
for (const event of events) {
this.knownEventTimestamps.set(event.id, event.created_at);
}
this.loadUnpublishedEventsSync();
} catch (e) {
console.log("unable to warm up from cache");
}
const endTime = performance.now();
console.log(`NDKCacheAdapterSqlite initialization took ${endTime - startTime}ms`);
}
/**
* Runs the function only if the cache adapter is ready, if it's not ready,
* it ignores the function.
*/
query(subscription) {
const cacheFilters = filterForCache(subscription);
const results = new Map();
const addResults = events => {
for (const event of events) {
results.set(event.id, event);
}
};
// Process filters from the subscription
for (const filter of cacheFilters) {
const hasHashtagFilter = Object.keys(filter).some(key => key.startsWith("#") && key.length === 2);
if (hasHashtagFilter) {
for (const key in filter) {
if (key.startsWith("#") && key.length === 2) {
const tag = key[1];
const events = this.db.getAllSync(`SELECT * FROM events INNER JOIN event_tags ON events.id = event_tags.event_id WHERE event_tags.tag = ? AND event_tags.value IN (${filter[key].map(() => "?").join(",")}) ORDER BY created_at DESC`, [tag, ...filter[key]]);
if (events.length > 0) addResults(foundEvents(subscription, events, filter));
break;
}
}
} else if (filter.authors && filter.kinds) {
const events = this.db.getAllSync(`SELECT * FROM events WHERE pubkey IN (${filter.authors.map(() => "?").join(",")}) AND kind IN (${filter.kinds.map(() => "?").join(",")}) ORDER BY created_at DESC`, [...filter.authors, ...filter.kinds]);
if (events.length > 0) addResults(foundEvents(subscription, events, filter));
} else if (filter.authors) {
const events = this.db.getAllSync(`SELECT * FROM events WHERE pubkey IN (${filter.authors.map(() => "?").join(",")}) ORDER BY created_at DESC`, filter.authors);
if (events.length > 0) addResults(foundEvents(subscription, events, filter));
} else if (filter.kinds) {
const events = this.db.getAllSync(`SELECT * FROM events WHERE kind IN (${filter.kinds.map(() => "?").join(",")}) ORDER BY created_at DESC`, filter.kinds);
if (events.length > 0) addResults(foundEvents(subscription, events, filter));
} else if (filter.ids) {
const events = this.db.getAllSync(`SELECT * FROM events WHERE id IN (${filter.ids.map(() => "?").join(",")}) ORDER BY created_at DESC`, filter.ids);
if (events.length > 0) addResults(foundEvents(subscription, events, filter));
} else {
console.log("\tš no logic on how to run this query in the sqlite cache", JSON.stringify(filter));
}
}
const ret = Array.from(results.values());
return ret;
}
async flushWriteBuffer() {
if (this.writeBuffer.length === 0) return;
const bufferCopy = [...this.writeBuffer];
this.writeBuffer = [];
await this.db.withTransactionAsync(async () => {
for (const [_index, {
query,
params
}] of bufferCopy.entries()) {
try {
await this.db.runAsync(query, params);
} catch (e) {
console.error("error executing buffered write", e, query, params);
}
}
});
// null out the timer
this.bufferFlushTimer = null;
// check if there are any more operations in the buffer
if (this.writeBuffer.length > 0) {
// console.log(`[${Date.now()}] [SQLITE] Buffer has ${this.writeBuffer.length} operations, starting new timer`);
this.bufferFlushTimer = setTimeout(() => this.flushWriteBuffer(), this.bufferFlushTimeout);
}
}
bufferWrite(query, params) {
this.writeBuffer.push({
query,
params
});
if (!this.bufferFlushTimer) {
this.bufferFlushTimer = setTimeout(() => this.flushWriteBuffer(), this.bufferFlushTimeout);
}
}
async setEvent(event, _filters, relay) {
const referenceId = event.isReplaceable() ? event.tagAddress() : event.id;
const existingEvent = this.knownEventTimestamps.get(referenceId);
if (existingEvent && existingEvent >= event.created_at) return;
this.knownEventTimestamps.set(referenceId, event.created_at);
if (event.isReplaceable()) {
try {
this.bufferWrite("DELETE FROM events WHERE id = ?;", [referenceId]);
this.bufferWrite("DELETE FROM event_tags WHERE event_id = ?;", [referenceId]);
} catch (e) {
console.error("error deleting event", e, referenceId);
}
}
// this.bufferKinds.set(event.kind!, (this.bufferKinds.get(event.kind!) || 0) + 1);
this.bufferWrite("INSERT INTO events (id, created_at, pubkey, event, kind, relay) VALUES (?, ?, ?, ?, ?, ?);", [referenceId, event.created_at, event.pubkey, event.serialize(true, true), event.kind, relay?.url || ""]);
const filterTags = event.tags.filter(tag => tag[0].length === 1).map(tag => [tag[0], tag[1]]);
if (filterTags.length < 10) {
for (const tag of filterTags) {
this.bufferWrite("INSERT INTO event_tags (event_id, tag, value) VALUES (?, ?, ?);", [event.id, tag[0], tag[1]]);
}
}
if (event.kind === NDKKind.EventDeletion) {
this.deleteEventIds(event.tags.filter(tag => tag[0] === "e").map(tag => tag[1]));
} else if (event.kind === NDKKind.Metadata) {
const profile = profileFromEvent(event);
if (profile) {
this.saveProfile(event.pubkey, profile);
}
}
}
async deleteEventIds(eventIds) {
// No longer need onReady wrapper
(async () => {
this.bufferWrite(`DELETE FROM events WHERE id IN (${eventIds.map(() => "?").join(",")});`, eventIds);
this.bufferWrite(`DELETE FROM event_tags WHERE event_id IN (${eventIds.map(() => "?").join(",")});`, eventIds);
})();
}
fetchProfileSync(pubkey) {
let result = null;
try {
result = this.db.getFirstSync("SELECT name, about, picture, banner, nip05, lud16, lud06, display_name, website, catched_at, created_at FROM profiles WHERE pubkey = ?;", [pubkey]);
} catch (e) {
console.error("error fetching profile", e, {
pubkey
});
return null;
}
if (result) {
try {
const profile = {
name: result.name,
about: result.about,
picture: result.picture,
banner: result.banner,
nip05: result.nip05,
lud16: result.lud16,
lud06: result.lud06,
displayName: result.display_name,
website: result.website,
created_at: result.created_at
};
const entry = {
...profile,
fetchedAt: result.catched_at
};
return entry;
} catch (_e) {
console.error("failed to parse profile", result);
}
}
return null;
}
async fetchProfile(pubkey) {
// No longer need ifReady wrapper
return await (async () => {
const result = await this.db.getFirstAsync(`
SELECT
name,
about,
picture,
banner,
nip05,
lud16,
lud06,
display_name,
website,
catched_at,
created_at
FROM profiles WHERE pubkey = ?;`, [pubkey]);
if (result) {
try {
const profile = {
name: result.name,
about: result.about,
picture: result.picture,
banner: result.banner,
nip05: result.nip05,
lud16: result.lud16,
lud06: result.lud06,
displayName: result.display_name,
website: result.website,
created_at: result.created_at
};
const entry = {
...profile,
fetchedAt: result.catched_at
};
return entry;
} catch (_e) {
console.error("failed to parse profile", result);
}
}
return null;
})();
}
async saveProfile(pubkey, profile) {
// No longer need onReady wrapper
(async () => {
// check if the profile we have is newer based on created_at
const existingProfile = await this.fetchProfile(pubkey);
if (existingProfile?.created_at && profile.created_at && existingProfile.created_at >= profile.created_at) {
return;
}
const now = this.nowSeconds();
const _entry = {
...profile,
fetchedAt: now
};
this.bufferWrite(`INSERT OR REPLACE INTO profiles (
pubkey,
name,
about,
picture,
banner,
nip05,
lud16,
lud06,
display_name,
website,
catched_at,
created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);`, [pubkey, profile.name, profile.about, profile.picture, profile.banner, profile.nip05, profile.lud16, profile.lud06, profile.displayName, profile.website, now, profile.created_at]);
})();
}
_unpublishedEvents = [];
_addUnpublishedEvent(event, relays, lastTryAt) {
if (this.unpublishedEventIds.has(event.id)) return;
this.unpublishedEventIds.add(event.id);
this._unpublishedEvents.push({
event,
relays,
lastTryAt
});
const onPublished = _relay => {
this.discardUnpublishedEvent(event.id);
event.off("published", onPublished);
};
event.on("published", onPublished);
}
addUnpublishedEvent(event, relayUrls) {
if (this.unpublishedEventIds.has(event.id)) return;
this._addUnpublishedEvent(event, relayUrls, Date.now());
this.setEvent(event, []);
// No longer need onReady wrapper
(async () => {
const relayStatus = {};
relayUrls.forEach(url => relayStatus[url] = false);
try {
// console.log(`[${Date.now()}] [SQLITE] write unpublished event`, event.kind);
this.db.runAsync("INSERT INTO unpublished_events (id, event, relays, last_try_at) VALUES (?, ?, ?, ?);", [event.id, event.serialize(true, true), JSON.stringify(relayStatus), Date.now()]);
} catch (e) {
console.error("error adding unpublished event", e);
}
})();
}
async getUnpublishedEvents() {
// Removed onReady wrapper and related logic.
// Returning empty array as this._unpublishedEvents is not defined.
// TODO: Review the logic for retrieving unpublished events.
return Promise.resolve([]);
}
/**
* This loads the unpublished events from the database into _unpublishedEvents.
*
* This should be called during initialization.
*/
loadUnpublishedEventsSync() {
const events = this.db.getAllSync("SELECT * FROM unpublished_events");
for (const event of events) {
const deserializedEvent = new NDKEvent(undefined, deserialize(event.event));
const relays = JSON.parse(event.relays);
this._addUnpublishedEvent(deserializedEvent, Object.keys(relays), event.last_try_at);
}
}
discardUnpublishedEvent(eventId) {
this.unpublishedEventIds.delete(eventId);
// No longer need onReady wrapper
(() => {
// console.log(`[${Date.now()}] [SQLITE] delete unpublished event`, eventId);
this.db.runAsync("DELETE FROM unpublished_events WHERE id = ?;", [eventId]);
})();
}
async saveWot(wot) {
// No longer need onReady wrapper
(async () => {
this.db.runSync("DELETE FROM wot;");
for (const [pubkey, value] of wot) {
this.db.runSync("INSERT INTO wot (pubkey, wot) VALUES (?, ?);", [pubkey, value]);
}
})();
}
async fetchWot() {
// No longer need onReady wrapper
return await (async () => {
const wot = await this.db.getAllAsync("SELECT * FROM wot");
return new Map(wot.map(wot => [wot.pubkey, wot.wot]));
})();
}
async clear() {
// No longer need onReady wrapper
(() => {
this.db.runSync("DELETE FROM wot;");
this.db.runSync("DELETE FROM profiles;");
this.db.runSync("DELETE FROM events;");
this.db.runSync("DELETE FROM event_tags;");
this.db.runSync("DELETE FROM unpublished_events;");
this.db.runSync("DELETE FROM decrypted_events;");
// Also clear nutzap state table if needed
this.db.runSync("DELETE FROM nutzap_monitor_state;");
})();
}
/**
* Get an event from the database by ID.
*
* @param id - The ID of the event to get.
* @returns The event, or null if it doesn't exist.
*/
getEventId(id) {
const row = this.db.getFirstSync("SELECT event FROM events WHERE id = ?;", [id]);
return row ? new NDKEvent(undefined, deserialize(row.event)) : null;
}
/**
* This function runs a query and returns parsed events.
* @param query The query to run.
* @param params The parameters to pass to the query.
* @param filterFn An optional filter function to filter events before deserializing them.
* @returns
*/
getEvents(query, params, filterFn) {
let res = this.db.getAllSync(query, params);
if (filterFn) {
res = res.filter(filterFn);
}
// deserialize the events
return res.map(record => {
try {
const deserializedEvent = deserialize(record.event);
return new NDKEvent(undefined, deserializedEvent);
} catch (e) {
console.error("failed to deserialize event", e, record);
return null;
}
});
}
/**
* Get a decrypted event from the database by ID.
*
* @param eventId - The ID of the decrypted event to get.
* @returns The decrypted event, or null if it doesn't exist.
*/
getDecryptedEvent(eventId) {
try {
const row = this.db.getFirstSync("SELECT event FROM decrypted_events WHERE event_id = ?;", [eventId]);
if (!row) return null;
return new NDKEvent(undefined, deserialize(row.event));
} catch (e) {
console.error("Error getting decrypted event", e, {
eventId
});
return null;
}
}
/**
* Store a decrypted event in the database.
*
* @param event - The decrypted event to store.
*/
addDecryptedEvent(event) {
// No longer need onReady wrapper
(() => {
const now = Date.now();
this.bufferWrite(`INSERT OR REPLACE INTO decrypted_events (
event_id,
event,
decrypted_at
) VALUES (?, ?, ?);`, [event.id, event.serialize(true, true), now]);
})();
}
/**
* Get the current timestamp in seconds
* @returns Current timestamp in seconds
*/
nowSeconds() {
return Math.floor(Date.now() / 1000);
}
/**
* Get the timestamp when a profile was last cached
* @param pubkey - The public key of the user
* @returns Timestamp in seconds when the profile was cached, or null if not found
*/
getProfileCacheTimestamp(pubkey) {
try {
const result = this.db.getFirstSync("SELECT catched_at FROM profiles WHERE pubkey = ?;", [pubkey]);
return result ? result.catched_at : null;
} catch (e) {
console.error("Error getting profile cache timestamp", e);
return null;
}
}
/**
* Update the cache timestamp for a profile
* Used when checking for updates but not finding anything newer
* @param pubkey - The user's public key
*/
updateProfileCacheTimestamp(pubkey) {
try {
const now = this.nowSeconds();
this.db.runSync("UPDATE profiles SET catched_at = ? WHERE pubkey = ?;", [now, pubkey]);
} catch (e) {
console.error("Error updating profile cache timestamp", e);
}
}
/**
* Sets the state of a nutzap in the cache.
* This method prepares the update using prepareNutzapStateUpdate and then buffers the write.
* @param id The ID of the nutzap event.
* @param stateChange The partial state change to apply.
*/
async setNutzapState(id, stateChange) {
const updateDetails = await prepareNutzapStateUpdate.call(this, id, stateChange);
if (updateDetails) {
this.bufferWrite(updateDetails.query, updateDetails.params);
}
}
}
export function foundEvents(subscription, records, filter) {
const result = [];
let now;
for (const record of records) {
const event = foundEvent(subscription, record, record.relay, filter);
if (event) {
// check the event is not expired
const expiration = event.tagValue("expiration");
if (expiration) {
now ??= Math.floor(Date.now() / 1000);
if (now > Number.parseInt(expiration)) continue;
}
result.push(event);
if (filter?.limit && result.length >= filter.limit) break;
}
}
return result;
}
export function foundEvent(subscription, event, relayUrl, filter) {
try {
const deserializedEvent = deserialize(event.event);
if (filter && !matchFilter(filter, deserializedEvent)) return null;
const ndkEvent = new NDKEvent(undefined, deserializedEvent);
const relay = relayUrl ? subscription.pool.getRelay(relayUrl, false) : undefined;
ndkEvent.relay = relay;
return ndkEvent;
} catch (e) {
console.error("failed to deserialize event", e, event.event);
return null;
}
}
//# sourceMappingURL=index.js.map