SkateSpots/src/lib/offlineCache.ts
SkateSpots ef01c3a5ed fix(map): stop deleted spots flashing back from cache
A NIP-09 kind-5 delete is only honoured by relays and was never applied
locally, so on refresh the persisted React Query cache and the Dexie spot
cache re-rendered a just-deleted spot for a few seconds until fresh data
(or the admin hidden-label query) filtered it out.

Add src/lib/localDeletions.ts: a device-local tombstone keyed by coord
and event id, written optimistically by useDeleteEntity on the owner path
(rolled back if signing/publishing fails). The shared filterAdminHidden/
filterAdminHiddenMixed now also drop tombstoned items, so every list hook
filters them from first render with no per-hook changes. useSkateSpot
returns null for a tombstoned spot, and cacheSpots/cacheSpot refuse and
evict tombstoned rows. Prune by tombstone only (callers use limits 8-2000,
so pruning by 'not in fresh result' would wipe valid cached spots).
2026-10-09 09:03:16 +07:00

469 lines
13 KiB
TypeScript

import Dexie, { type Table } from 'dexie';
import type { NostrEvent } from '@nostrify/nostrify';
import { SKATE_SPOT_KIND, type SkateSpot } from '@/lib/types';
import { getLocalDeletions } from '@/lib/localDeletions';
// Define the database schema
interface CachedSpot {
id: string; // event id
pubkey: string;
identifier: string;
event: NostrEvent;
spot: SkateSpot;
cachedAt: number;
viewedAt: number;
}
interface PendingPost {
id: string; // local uuid
kind: number;
content: string;
tags: string[][];
createdAt: number;
status: 'pending' | 'publishing' | 'failed';
retries: number;
/**
* The ALREADY-SIGNED event, when we have it. Signing happens at the moment of
* the action (so an interactive signer like Amber is NOT re-prompted at sync
* time — the signature is just delivered later).
*/
signedEvent?: NostrEvent;
lastError?: string;
}
/**
* Kinds that are only true "now" — a queued one is worse than none (e.g. a
* spot-status "I'm here" delivered 3 hours later). Dropped once stale.
*/
const EPHEMERAL_KINDS = new Set<number>([2259, 24899]);
const EPHEMERAL_TTL_MS = 15 * 60 * 1000;
/** Hard cap so a long offline stretch can't grow the queue without bound. */
const MAX_PENDING = 100;
interface OfflineSettings {
key: string;
value: string;
}
/**
* The encrypted stash event, kept on device so the stash is still readable
* offline (and during a relay outage). We deliberately cache the CIPHERTEXT —
* exactly what relays already hold — never the decrypted list.
*/
interface CachedStashEvent {
pubkey: string;
event: NostrEvent;
cachedAt: number;
}
// Create the Dexie database
class SkateSpotsCacheDB extends Dexie {
spots!: Table<CachedSpot, string>;
pendingPosts!: Table<PendingPost, string>;
settings!: Table<OfflineSettings, string>;
stash!: Table<CachedStashEvent, string>;
constructor() {
super('SkateSpotsCacheDB');
this.version(1).stores({
spots: 'id, pubkey, identifier, [pubkey+identifier], cachedAt, viewedAt',
pendingPosts: 'id, kind, status, createdAt',
settings: 'key',
});
// v2 adds the encrypted-stash cache (see CachedStashEvent).
this.version(2).stores({
stash: 'pubkey, cachedAt',
});
}
}
// Singleton database instance
let db: SkateSpotsCacheDB | null = null;
function getDB(): SkateSpotsCacheDB {
if (!db) {
db = new SkateSpotsCacheDB();
}
return db;
}
// ============ Spot Caching ============
/**
* Cache a spot for offline viewing
*/
export async function cacheSpot(event: NostrEvent, spot: SkateSpot): Promise<void> {
const db = getDB();
const now = Date.now();
// Never re-cache a locally-deleted spot, and evict a stale cached copy.
const deleted = getLocalDeletions();
if (
deleted.has(`${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`) ||
deleted.has(`e:${event.id}`)
) {
await db.spots.delete(event.id);
return;
}
await db.spots.put({
id: event.id,
pubkey: spot.pubkey,
identifier: spot.identifier,
event,
spot,
cachedAt: now,
viewedAt: now,
});
}
/**
* Cache multiple spots at once
*/
export async function cacheSpots(spotsWithEvents: Array<{ event: NostrEvent; spot: SkateSpot }>): Promise<void> {
const db = getDB();
const now = Date.now();
const deleted = getLocalDeletions();
const isTombstoned = (event: NostrEvent, spot: SkateSpot) =>
deleted.has(`${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`) ||
deleted.has(`e:${event.id}`);
const keep =
deleted.size > 0
? spotsWithEvents.filter(({ event, spot }) => !isTombstoned(event, spot))
: spotsWithEvents;
if (keep.length > 0) {
await db.spots.bulkPut(
keep.map(({ event, spot }) => ({
id: event.id,
pubkey: spot.pubkey,
identifier: spot.identifier,
event,
spot,
cachedAt: now,
viewedAt: now,
}))
);
}
// Evict previously-cached rows that now carry a tombstone (deleted after they
// were cached), so the offline map can never resurrect a deleted spot.
if (deleted.size > 0) {
const stale = (await db.spots.toArray())
.filter(
(c) =>
deleted.has(`${SKATE_SPOT_KIND}:${c.pubkey}:${c.identifier}`) ||
deleted.has(`e:${c.id}`),
)
.map((c) => c.id);
if (stale.length > 0) await db.spots.bulkDelete(stale);
}
}
/**
* Get a cached spot by pubkey and identifier
*/
export async function getCachedSpot(pubkey: string, identifier: string): Promise<SkateSpot | null> {
const db = getDB();
const cached = await db.spots
.where('[pubkey+identifier]')
.equals([pubkey, identifier])
.first();
if (cached) {
// Update viewedAt
await db.spots.update(cached.id, { viewedAt: Date.now() });
return cached.spot;
}
return null;
}
/**
* Get all cached spots
*/
export async function getAllCachedSpots(): Promise<SkateSpot[]> {
const db = getDB();
const cached = await db.spots.toArray();
return cached.map(c => c.spot);
}
/**
* Get recently viewed spots (for offline browsing)
*/
export async function getRecentlyViewedSpots(limit: number = 50): Promise<SkateSpot[]> {
const db = getDB();
const cached = await db.spots
.orderBy('viewedAt')
.reverse()
.limit(limit)
.toArray();
return cached.map(c => c.spot);
}
/**
* Get cache statistics
*/
export async function getCacheStats(): Promise<{
spotCount: number;
oldestCachedAt: number | null;
newestCachedAt: number | null;
totalSizeEstimate: number;
}> {
const db = getDB();
const spots = await db.spots.toArray();
if (spots.length === 0) {
return {
spotCount: 0,
oldestCachedAt: null,
newestCachedAt: null,
totalSizeEstimate: 0,
};
}
const cachedTimes = spots.map(s => s.cachedAt);
const totalSize = spots.reduce((acc, s) => acc + JSON.stringify(s).length, 0);
return {
spotCount: spots.length,
oldestCachedAt: Math.min(...cachedTimes),
newestCachedAt: Math.max(...cachedTimes),
totalSizeEstimate: totalSize,
};
}
/**
* Clear old cached spots (older than maxAge in milliseconds)
*/
export async function clearOldCache(maxAgeMs: number = 7 * 24 * 60 * 60 * 1000): Promise<number> {
const db = getDB();
const cutoff = Date.now() - maxAgeMs;
const oldSpots = await db.spots
.where('cachedAt')
.below(cutoff)
.primaryKeys();
await db.spots.bulkDelete(oldSpots);
return oldSpots.length;
}
/**
* Clear all cached spots
*/
export async function clearAllCache(): Promise<void> {
const db = getDB();
await db.spots.clear();
}
// ============ Pending Posts Queue ============
/**
* Add a post to the pending queue (for posting when back online)
*/
export async function queuePost(kind: number, content: string, tags: string[][]): Promise<string> {
const db = getDB();
const id = crypto.randomUUID();
await db.pendingPosts.add({
id,
kind,
content,
tags,
createdAt: Date.now(),
status: 'pending',
retries: 0,
});
return id;
}
/**
* Get all pending posts
*/
export async function getPendingPosts(): Promise<PendingPost[]> {
const db = getDB();
// Anything not currently mid-publish: 'pending' AND retryable 'failed' items
// (a single failed attempt must not strand an action forever).
const all = await db.pendingPosts.toArray();
return all.filter((p) => p.status !== 'publishing').sort((a, b) => a.createdAt - b.createdAt);
}
/**
* Queue an ALREADY-SIGNED event (the offline path for `useNostrPublish`).
*
* Before adding we:
* • drop queued events this one SUPERSEDES — for a replaceable (kind 0/3/10000+)
* or addressable (30000+) event, only the newest version is worth delivering;
* • drop stale ephemeral events;
* • cap the queue.
* so a long offline stretch leaves the newest, still-meaningful actions.
*/
export async function queueSignedEvent(event: NostrEvent): Promise<string> {
const db = getDB();
const replaceable = event.kind === 0 || event.kind === 3 || (event.kind >= 10000 && event.kind < 20000);
const addressable = event.kind >= 30000 && event.kind < 40000;
if (replaceable || addressable) {
const d = event.tags.find(([n]) => n === 'd')?.[1] ?? '';
const existing = await db.pendingPosts.toArray();
const superseded = existing
.filter((p) => {
if (p.kind !== event.kind) return false;
if (p.signedEvent && p.signedEvent.pubkey !== event.pubkey) return false;
if (!addressable) return true;
return (p.tags.find(([n]) => n === 'd')?.[1] ?? '') === d;
})
.map((p) => p.id);
if (superseded.length > 0) await db.pendingPosts.bulkDelete(superseded);
}
const id = crypto.randomUUID();
await db.pendingPosts.add({
id,
kind: event.kind,
content: event.content,
tags: event.tags,
createdAt: Date.now(),
status: 'pending',
retries: 0,
signedEvent: event,
});
// Keep only the newest MAX_PENDING.
const count = await db.pendingPosts.count();
if (count > MAX_PENDING) {
const oldest = await db.pendingPosts.orderBy('createdAt').limit(count - MAX_PENDING).toArray();
await db.pendingPosts.bulkDelete(oldest.map((p) => p.id));
}
return id;
}
/** Drop queued ephemeral events that are past their useful life. */
export async function prunePendingPosts(): Promise<number> {
const db = getDB();
const now = Date.now();
const stale = (await db.pendingPosts.toArray()).filter(
(p) => EPHEMERAL_KINDS.has(p.kind) && now - p.createdAt > EPHEMERAL_TTL_MS,
);
if (stale.length > 0) await db.pendingPosts.bulkDelete(stale.map((p) => p.id));
return stale.length;
}
/**
* Update a pending post's status
*/
export async function updatePendingPostStatus(
id: string,
status: 'pending' | 'publishing' | 'failed',
retries?: number
): Promise<void> {
const db = getDB();
const update: Partial<PendingPost> = { status };
if (retries !== undefined) {
update.retries = retries;
}
await db.pendingPosts.update(id, update);
}
/**
* Remove a pending post (after successful publish)
*/
export async function removePendingPost(id: string): Promise<void> {
const db = getDB();
await db.pendingPosts.delete(id);
}
/**
* Get pending posts count
*/
export async function getPendingPostsCount(): Promise<number> {
const db = getDB();
return db.pendingPosts.count();
}
/**
* Requeue posts left in `publishing` by a crash/reload. Without this they are
* never retried (`getPendingPosts` skips `publishing`) yet still counted, so the
* action silently disappears while the badge claims it is still pending.
*/
export async function reconcileStalePendingPosts(): Promise<void> {
const db = getDB();
const all = await db.pendingPosts.toArray();
const stuck = all.filter((p) => p.status === 'publishing');
await Promise.all(stuck.map((p) => db.pendingPosts.update(p.id, { status: 'pending' })));
}
// ============ Encrypted Stash Cache (offline readability) ============
/**
* Remember the encrypted stash event so it can be decrypted offline. Stores the
* CIPHERTEXT only — never the decrypted list.
*/
export async function cacheStashEvent(pubkey: string, event: NostrEvent): Promise<void> {
const db = getDB();
await db.stash.put({ pubkey, event, cachedAt: Date.now() });
}
/** The last encrypted stash event we saw for this pubkey, if any. */
export async function getCachedStashEvent(pubkey: string): Promise<NostrEvent | undefined> {
const db = getDB();
try {
const row = await db.stash.get(pubkey);
return row?.event;
} catch {
return undefined;
}
}
export async function clearCachedStash(pubkey: string): Promise<void> {
const db = getDB();
await db.stash.delete(pubkey);
}
// ============ Offline Detection ============
/**
* Check if the app is currently offline
*/
export function isOffline(): boolean {
return !navigator.onLine;
}
/**
* Listen for online/offline status changes
*/
export function onConnectionChange(callback: (isOnline: boolean) => void): () => void {
const handleOnline = () => callback(true);
const handleOffline = () => callback(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}
// ============ Settings ============
/**
* Store a setting in the offline cache
*/
export async function setSetting(key: string, value: string): Promise<void> {
const db = getDB();
await db.settings.put({ key, value });
}
/**
* Get a setting from the offline cache
*/
export async function getSetting(key: string): Promise<string | null> {
const db = getDB();
const setting = await db.settings.get(key);
return setting?.value ?? null;
}