1import { expiration, type Event } from "./event.ts";23export type UnsignedEvent = { kind: number; created_at: number; tags: string[][]; content: string };4type HistorySQL = <T extends Record<string, SqlStorageValue>>(q: string, ...args: unknown[]) => SqlStorageCursor<T>;56// List history keeps a small private undo trail for the replaceable lists that7// are difficult to recreate. It is keyed by the signing pubkey, never indexed8// as events, and only the same signer can ask for a version back.9export const LIST_KINDS = [3, 10002, 10003, 30003] as const;10export const LIST_HISTORY_LIMIT = 12;11export const LIST_HISTORY_OWNER_LIMIT = 96;12export const LIST_HISTORY_GLOBAL_LIMIT = 4096;13export const LIST_HISTORY_SCHEMA = `14CREATE TABLE IF NOT EXISTS list_history (15 owner TEXT NOT NULL,16 kind INTEGER NOT NULL,17 d TEXT NOT NULL DEFAULT '',18 event_id TEXT NOT NULL,19 created_at INTEGER NOT NULL,20 saved_at INTEGER NOT NULL,21 expires INTEGER NOT NULL DEFAULT 0,22 raw TEXT NOT NULL,23 PRIMARY KEY (owner, kind, d, event_id)24);25CREATE INDEX IF NOT EXISTS list_history_owner ON list_history(owner, saved_at DESC);26CREATE INDEX IF NOT EXISTS list_history_list ON list_history(owner, kind, d, created_at DESC);27`;2829const listKind = (kind: number) => (LIST_KINDS as readonly number[]).includes(kind);30export const isListKind = listKind;31const listD = (e: Pick<Event, "kind" | "tags">) => e.kind >= 30000 ? e.tags.find((t: Event["tags"][number]) => t[0] === "d")?.[1] ?? "" : "";3233// archiveCurrent records the version about to be replaced, then trims the34// owner's trail. The current event remains available through ordinary NIP-0135// queries; history is an internal recovery aid.36export const archiveCurrent = (x: HistorySQL, e: Event, savedAt: number) => {37 if (!listKind(e.kind)) return;38 const d = listD(e);39 x(`INSERT OR IGNORE INTO list_history(owner,kind,d,event_id,created_at,saved_at,expires,raw) VALUES(?,?,?,?,?,?,?,?)`, e.pubkey, e.kind, d, e.id, e.created_at, savedAt, expiration(e), JSON.stringify(e));40 x(`DELETE FROM list_history WHERE owner=? AND kind=? AND d=? AND event_id NOT IN (SELECT event_id FROM list_history WHERE owner=? AND kind=? AND d=? ORDER BY created_at DESC, event_id ASC LIMIT ?)`, e.pubkey, e.kind, d, e.pubkey, e.kind, d, LIST_HISTORY_LIMIT);41 x(`DELETE FROM list_history WHERE owner=? AND event_id NOT IN (SELECT event_id FROM list_history WHERE owner=? ORDER BY saved_at DESC, event_id ASC LIMIT ?)`, e.pubkey, e.pubkey, LIST_HISTORY_OWNER_LIMIT);42 x(`DELETE FROM list_history WHERE event_id NOT IN (SELECT event_id FROM list_history ORDER BY saved_at DESC, event_id ASC LIMIT ?)`, LIST_HISTORY_GLOBAL_LIMIT);43};4445// clearForDelete removes versions an author's NIP-09 deletion names. A list46// deletion must not leave private old tags recoverable through this feature.47export const clearForDelete = (x: HistorySQL, author: string, eventID: string) => {48 x(`DELETE FROM list_history WHERE owner=? AND event_id=?`, author, eventID);49};5051export const clearList = (x: HistorySQL, owner: string, kind: number, d: string, before?: number) => {52 if (before === undefined) x(`DELETE FROM list_history WHERE owner=? AND kind=? AND d=?`, owner, kind, d);53 else x(`DELETE FROM list_history WHERE owner=? AND kind=? AND d=? AND created_at<=?`, owner, kind, d, before);54};5556export interface ListHistoryRow { owner: string; kind: number; d: string; event_id: string; created_at: number; saved_at: number; raw: string; }5758export const listHistory = (x: HistorySQL, owner: string, now: number): Omit<ListHistoryRow, "owner" | "raw">[] => {59 x(`DELETE FROM list_history WHERE owner=? AND expires>0 AND expires<=?`, owner, now);60 return x<Omit<ListHistoryRow, "owner" | "raw">>(`SELECT kind,d,event_id,created_at,saved_at FROM list_history WHERE owner=? AND (expires=0 OR expires>?) ORDER BY saved_at DESC LIMIT ?`, owner, now, LIST_HISTORY_OWNER_LIMIT).toArray();61};6263export const restoreHistory = (x: HistorySQL, owner: string, eventID: string, now: number): { draft: UnsignedEvent; diff: { addedTags: string[][]; removedTags: string[][]; contentChanged: boolean } } | string => {64 x(`DELETE FROM list_history WHERE owner=? AND expires>0 AND expires<=?`, owner, now);65 const row = x<{ raw: string }>(`SELECT raw FROM list_history WHERE owner=? AND event_id=? AND (expires=0 OR expires>?)`, owner, eventID, now).toArray()[0];66 if (!row) return "not found";67 try {68 const event = JSON.parse(row.raw) as Event;69 const current = x<{ created_at: number; raw: string }>(`SELECT created_at,raw FROM events WHERE pubkey=? AND kind=? AND d=?`, owner, event.kind, listD(event)).toArray()[0];70 let currentEvent: Event | undefined;71 try { currentEvent = current ? JSON.parse(current.raw) as Event : undefined; } catch { /* saved current rows are validated events */ }72 const key = (t: string[]) => JSON.stringify(t);73 const currentTags = new Set((currentEvent?.tags ?? []).map(key));74 const oldTags = new Set(event.tags.map(key));75 return {76 draft: { kind: event.kind, created_at: Math.max(Math.floor(Date.now() / 1000), (current?.created_at ?? 0) + 1), tags: event.tags, content: event.content },77 diff: { addedTags: event.tags.filter((t) => !currentTags.has(key(t))), removedTags: (currentEvent?.tags ?? []).filter((t) => !oldTags.has(key(t))), contentChanged: (currentEvent?.content ?? "") !== event.content },78 };79 } catch {80 return "error: saved list is unreadable";81 }82};