1 // db.js — IndexedDB operations for events and DMs
2
3 import { sha256, bytesToHex } from './crypto.js'
4
5 const DB_NAME = 'smesh2'
6 const DB_VERSION = 2
7
8 function openDB() {
9 return new Promise((resolve, reject) => {
10 const req = indexedDB.open(DB_NAME, DB_VERSION)
11 req.onupgradeneeded = (e) => {
12 const db = e.target.result
13 if (!db.objectStoreNames.contains('events')) {
14 const store = db.createObjectStore('events', { keyPath: 'id' })
15 store.createIndex('pubkey', 'pubkey', { unique: false })
16 store.createIndex('kind', 'kind', { unique: false })
17 store.createIndex('pubkey_kind', ['pubkey', 'kind'], { unique: false })
18 store.createIndex('created_at', 'created_at', { unique: false })
19 }
20 if (!db.objectStoreNames.contains('dms')) {
21 const dms = db.createObjectStore('dms', { keyPath: 'id' })
22 dms.createIndex('peer', 'peer', { unique: false })
23 dms.createIndex('peer_ts', ['peer', 'created_at'], { unique: false })
24 }
25 }
26 req.onsuccess = () => resolve(patchDBClose(req.result))
27 req.onerror = () => reject(req.error)
28 })
29 }
30
31 let dbPromise = openDB()
32
33 export async function getDB() {
34 const db = await dbPromise
35 if (db._closed) {
36 dbPromise = openDB()
37 return dbPromise
38 }
39 return db
40 }
41
42 function patchDBClose(db) {
43 db._closed = false
44 db.onclose = () => { db._closed = true }
45 db.addEventListener('close', () => { db._closed = true })
46 return db
47 }
48
49 // ─── events ─────────────────────────────────────────────────────────
50
51 export async function saveEvent(event) {
52 const db = await getDB()
53 return new Promise((resolve, reject) => {
54 const tx = db.transaction('events', 'readwrite')
55 const store = tx.objectStore('events')
56 const req = store.put(event)
57 req.onsuccess = () => resolve(true)
58 req.onerror = () => {
59 if (req.error?.name === 'ConstraintError') resolve(false)
60 else reject(req.error)
61 }
62 })
63 }
64
65 export async function queryEvents(filter) {
66 const db = await getDB()
67 return new Promise((resolve, reject) => {
68 const tx = db.transaction('events', 'readonly')
69 const store = tx.objectStore('events')
70 const results = []
71
72 let source
73 if (filter.authors?.length === 1 && filter.kinds?.length === 1) {
74 const idx = store.index('pubkey_kind')
75 const key = IDBKeyRange.only([filter.authors[0], filter.kinds[0]])
76 source = idx.openCursor(key, 'prev')
77 } else if (filter.authors?.length === 1) {
78 source = store.index('pubkey').openCursor(
79 IDBKeyRange.only(filter.authors[0]), 'prev'
80 )
81 } else if (filter.kinds?.length === 1) {
82 source = store.index('kind').openCursor(
83 IDBKeyRange.only(filter.kinds[0]), 'prev'
84 )
85 } else {
86 source = store.index('created_at').openCursor(null, 'prev')
87 }
88
89 source.onsuccess = (e) => {
90 const cursor = e.target.result
91 if (!cursor) { resolve(results); return }
92
93 const ev = cursor.value
94 let match = true
95
96 if (filter.ids && !filter.ids.includes(ev.id)) match = false
97 if (filter.authors?.length > 1 && !filter.authors.includes(ev.pubkey)) match = false
98 if (filter.kinds?.length > 1 && !filter.kinds.includes(ev.kind)) match = false
99 if (filter.since && ev.created_at < filter.since) match = false
100 if (filter.until && ev.created_at > filter.until) match = false
101
102 if (match) {
103 for (const [k, v] of Object.entries(filter)) {
104 if (k.startsWith('#') && k.length === 2) {
105 const tagName = k[1]
106 const hasTag = ev.tags?.some(
107 (t) => t[0] === tagName && v.includes(t[1])
108 )
109 if (!hasTag) { match = false; break }
110 }
111 }
112 }
113
114 if (match) results.push(ev)
115 if (filter.limit && results.length >= filter.limit) {
116 resolve(results)
117 return
118 }
119 cursor.continue()
120 }
121 source.onerror = () => reject(source.error)
122 })
123 }
124
125 // ─── DMs ────────────────────────────────────────────────────────────
126
127 export function dmDedupId(peerPubkey, content, createdAt) {
128 const contentHash = bytesToHex(sha256(new TextEncoder().encode(content)))
129 const timeWindow = Math.floor(createdAt / 300)
130 return bytesToHex(sha256(new TextEncoder().encode(peerPubkey + contentHash + timeWindow)))
131 }
132
133 export async function saveDM(dm) {
134 const db = await getDB()
135 return new Promise((resolve, reject) => {
136 const tx = db.transaction('dms', 'readwrite')
137 const store = tx.objectStore('dms')
138 const getReq = store.get(dm.id)
139 getReq.onsuccess = () => {
140 const existing = getReq.result
141 if (existing) {
142 if (dm.protocol === 'nip17' && existing.protocol === 'nip04') {
143 store.put(dm)
144 resolve('upgraded')
145 } else {
146 resolve('duplicate')
147 }
148 } else {
149 store.put(dm)
150 resolve('saved')
151 }
152 }
153 getReq.onerror = () => reject(getReq.error)
154 })
155 }
156
157 export async function queryDMs(peer, limit = 50, until = null) {
158 const db = await getDB()
159 return new Promise((resolve, reject) => {
160 const tx = db.transaction('dms', 'readonly')
161 const store = tx.objectStore('dms')
162 const idx = store.index('peer_ts')
163 const results = []
164
165 const upper = until ? [peer, until] : [peer, Date.now() / 1000 + 86400]
166 const lower = [peer, 0]
167 const range = IDBKeyRange.bound(lower, upper)
168 const req = idx.openCursor(range, 'prev')
169
170 req.onsuccess = (e) => {
171 const cursor = e.target.result
172 if (!cursor || results.length >= limit) { resolve(results); return }
173 results.push(cursor.value)
174 cursor.continue()
175 }
176 req.onerror = () => reject(req.error)
177 })
178 }
179
180 export async function getConversationList() {
181 const db = await getDB()
182 return new Promise((resolve, reject) => {
183 const tx = db.transaction('dms', 'readonly')
184 const store = tx.objectStore('dms')
185 const idx = store.index('peer')
186 const conversations = new Map()
187
188 const req = idx.openCursor(null, 'prev')
189 req.onsuccess = (e) => {
190 const cursor = e.target.result
191 if (!cursor) {
192 resolve([...conversations.values()].sort((a, b) => b.lastTs - a.lastTs))
193 return
194 }
195 const dm = cursor.value
196 if (!conversations.has(dm.peer)) {
197 conversations.set(dm.peer, {
198 peer: dm.peer,
199 lastMessage: dm.content.slice(0, 80),
200 lastTs: dm.created_at,
201 from: dm.from,
202 })
203 } else {
204 const existing = conversations.get(dm.peer)
205 if (dm.created_at > existing.lastTs) {
206 existing.lastMessage = dm.content.slice(0, 80)
207 existing.lastTs = dm.created_at
208 existing.from = dm.from
209 }
210 }
211 cursor.continue()
212 }
213 req.onerror = () => reject(req.error)
214 })
215 }
216