SkateSpots/src/lib/offlineCache.ts
SkateSpots 5a8e3688ee feat(stash): readable offline + export/import backup
The private stash was deny-listed from persistence with no local cache, so an
offline or relay-outage cold start showed an EMPTY stash even though decrypting
needs no network.

- offlineCache v2 adds a 'stash' table storing the encrypted CIPHERTEXT (never
  the decrypted list). useStash caches it on every successful fetch, and both
  the read query and fetchLatestStash (the pre-write re-fetch) fall back to it --
  so the stash loads at the park and an offline edit rebuilds from the real list
  instead of throwing.
- /stash gains a 'Backup & transfer' card (desktop sidebar + mobile) with
  Export (a JSON you control) and Import (one re-encrypt, skipping
  title+category+size duplicates).
2026-09-29 22:20:50 +07:00

421 lines
11 KiB
TypeScript

import Dexie, { type Table } from 'dexie';
import type { NostrEvent } from '@nostrify/nostrify';
import type { SkateSpot } from '@/lib/types';
// 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();
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();
await db.spots.bulkPut(
spotsWithEvents.map(({ event, spot }) => ({
id: event.id,
pubkey: spot.pubkey,
identifier: spot.identifier,
event,
spot,
cachedAt: now,
viewedAt: now,
}))
);
}
/**
* 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();
}
// ============ 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;
}