idb.mjs raw
1 // TinyJS Runtime — IndexedDB store for events and DMs
2 // Port of smesh2/db.js with same schema and query logic.
3
4 const DB_NAME = 'sm3sh';
5 const DB_VERSION = 4;
6
7 let _db = null;
8 let _dbPromise = null;
9 let _expectedVersion = '';
10
11 function openDB() {
12 return new Promise((resolve, reject) => {
13 const req = indexedDB.open(DB_NAME, DB_VERSION);
14 req.onupgradeneeded = (e) => {
15 const db = e.target.result;
16 if (!db.objectStoreNames.contains('profiles')) db.createObjectStore('profiles');
17 if (!db.objectStoreNames.contains('relays')) db.createObjectStore('relays');
18 if (!db.objectStoreNames.contains('events')) {
19 const store = db.createObjectStore('events', { keyPath: 'id' });
20 store.createIndex('pubkey', 'pubkey', { unique: false });
21 store.createIndex('kind', 'kind', { unique: false });
22 store.createIndex('pubkey_kind', ['pubkey', 'kind'], { unique: false });
23 store.createIndex('created_at', 'created_at', { unique: false });
24 }
25 if (!db.objectStoreNames.contains('dms')) {
26 const dms = db.createObjectStore('dms', { keyPath: 'id' });
27 dms.createIndex('peer', 'peer', { unique: false });
28 dms.createIndex('peer_ts', ['peer', 'created_at'], { unique: false });
29 }
30 if (!db.objectStoreNames.contains('meta')) db.createObjectStore('meta');
31 };
32 req.onblocked = () => { console.warn('[sm3sh-sw] IDB upgrade blocked'); };
33 req.onsuccess = () => {
34 const db = req.result;
35 db._closed = false;
36 db.onclose = () => { db._closed = true; };
37 db.onversionchange = () => { db.close(); db._closed = true; _db = null; };
38 if (_expectedVersion) {
39 _epochCheck(db).then(() => resolve(db)).catch(() => resolve(db));
40 } else {
41 resolve(db);
42 }
43 };
44 req.onerror = () => reject(req.error);
45 });
46 }
47
48 function _epochCheck(db) {
49 return new Promise((resolve) => {
50 const tx = db.transaction('meta', 'readonly');
51 const store = tx.objectStore('meta');
52 const req = store.get('_version');
53 req.onsuccess = () => {
54 const stored = req.result;
55 if (stored === _expectedVersion) { resolve(); return; }
56 console.warn('[sm3sh-sw] version epoch mismatch: stored=' + (stored || 'none') + ' running=' + _expectedVersion + ' — flushing IDB');
57 _flushAndStamp(db).then(resolve);
58 };
59 req.onerror = () => { _flushAndStamp(db).then(resolve); };
60 });
61 }
62
63 function _flushAndStamp(db) {
64 return new Promise((resolve) => {
65 const names = ['profiles', 'relays', 'events', 'dms'];
66 const tx = db.transaction([...names, 'meta'], 'readwrite');
67 for (const name of names) tx.objectStore(name).clear();
68 tx.objectStore('meta').put(_expectedVersion, '_version');
69 tx.oncomplete = () => { resolve(); };
70 tx.onerror = () => { resolve(); };
71 });
72 }
73
74 async function getDB() {
75 if (_db && !_db._closed) return _db;
76 if (!_dbPromise) _dbPromise = openDB();
77 _db = await _dbPromise;
78 _dbPromise = null;
79 return _db;
80 }
81
82 // --- Exports for Go jsbridge ---
83
84 export function Open(fn) {
85 getDB().then(() => fn());
86 }
87
88 export function SaveEvent(eventJSON, fn) {
89 let event;
90 try { event = JSON.parse(eventJSON); } catch (e) {
91 console.error('SaveEvent JSON parse error:', e.message);
92 fn(false); return;
93 }
94 getDB().then(db => {
95 try {
96 const tx = db.transaction('events', 'readwrite');
97 const store = tx.objectStore('events');
98 const req = store.put(event);
99 req.onsuccess = () => { try { fn(true); } catch(e) { _busErr('SaveEvent cb', e); } };
100 req.onerror = () => {
101 if (req.error?.name === 'ConstraintError') fn(false);
102 else { console.warn('SaveEvent error:', req.error); fn(false); }
103 };
104 } catch (e) {
105 console.error('SaveEvent tx error:', e.message);
106 _busErr('SaveEvent tx', e);
107 fn(false);
108 }
109 }).catch(e => { console.error('SaveEvent getDB error:', e.message); _busErr('SaveEvent getDB', e); fn(false); });
110 }
111
112 function _busErr(ctx, e) {
113 if (self._busPort) {
114 self._busPort.postMessage('{"from":"relay","to":"shell","msg":["LOG","relay","IDB CRASH ' + ctx + ': ' + String(e.message).replace(/"/g, '\\"').replace(/\n/g, ' ') + '"]}');
115 }
116 }
117
118 export function QueryEvents(filterJSON, fn) {
119 const filter = JSON.parse(filterJSON);
120 getDB().then(db => {
121 const tx = db.transaction('events', 'readonly');
122 const store = tx.objectStore('events');
123 const results = [];
124
125 let source;
126 if (filter.authors?.length === 1 && filter.kinds?.length === 1) {
127 const idx = store.index('pubkey_kind');
128 source = idx.openCursor(IDBKeyRange.only([filter.authors[0], filter.kinds[0]]), 'prev');
129 } else if (filter.authors?.length === 1) {
130 source = store.index('pubkey').openCursor(IDBKeyRange.only(filter.authors[0]), 'prev');
131 } else if (filter.kinds?.length === 1) {
132 source = store.index('kind').openCursor(IDBKeyRange.only(filter.kinds[0]), 'prev');
133 } else {
134 source = store.index('created_at').openCursor(null, 'prev');
135 }
136
137 source.onsuccess = (e) => {
138 const cursor = e.target.result;
139 if (!cursor) { fn(JSON.stringify(results)); return; }
140
141 const ev = cursor.value;
142 let match = true;
143
144 if (filter.ids && !filter.ids.includes(ev.id)) match = false;
145 if (filter.authors?.length > 1 && !filter.authors.includes(ev.pubkey)) match = false;
146 if (filter.kinds?.length > 1 && !filter.kinds.includes(ev.kind)) match = false;
147 if (filter.since && ev.created_at < filter.since) match = false;
148 if (filter.until && ev.created_at > filter.until) match = false;
149
150 if (match) {
151 for (const [k, v] of Object.entries(filter)) {
152 if (k.startsWith('#') && k.length === 2) {
153 const tagName = k[1];
154 if (!ev.tags?.some(t => t[0] === tagName && v.includes(t[1]))) { match = false; break; }
155 }
156 }
157 }
158
159 if (match) results.push(ev);
160 if (filter.limit && results.length >= filter.limit) { fn(JSON.stringify(results)); return; }
161 cursor.continue();
162 };
163 source.onerror = () => { console.warn('QueryEvents error:', source.error); fn('[]'); };
164 });
165 }
166
167 export function SaveDM(dmJSON, fn) {
168 const dm = JSON.parse(dmJSON);
169 getDB().then(db => {
170 const tx = db.transaction('dms', 'readwrite');
171 const store = tx.objectStore('dms');
172 const getReq = store.get(dm.id);
173 getReq.onsuccess = () => {
174 const existing = getReq.result;
175 if (existing) {
176 if (dm.protocol === 'nip17' && existing.protocol === 'nip04') {
177 store.put(dm);
178 fn('upgraded');
179 } else {
180 fn('duplicate');
181 }
182 } else {
183 store.put(dm);
184 fn('saved');
185 }
186 };
187 getReq.onerror = () => { console.warn('SaveDM error:', getReq.error); fn('error'); };
188 });
189 }
190
191 export function QueryDMs(peer, limit, until, fn) {
192 getDB().then(db => {
193 const tx = db.transaction('dms', 'readonly');
194 const store = tx.objectStore('dms');
195 const idx = store.index('peer_ts');
196 const results = [];
197
198 const upper = until > 0 ? [peer, until] : [peer, Date.now() / 1000 + 86400];
199 const range = IDBKeyRange.bound([peer, 0], upper);
200 const req = idx.openCursor(range, 'prev');
201
202 req.onsuccess = (e) => {
203 const cursor = e.target.result;
204 if (!cursor || results.length >= limit) { fn(JSON.stringify(results)); return; }
205 results.push(cursor.value);
206 cursor.continue();
207 };
208 req.onerror = () => { console.warn('QueryDMs error:', req.error); fn('[]'); };
209 });
210 }
211
212 export function GetConversationList(fn) {
213 getDB().then(db => {
214 const tx = db.transaction('dms', 'readonly');
215 const store = tx.objectStore('dms');
216 const idx = store.index('peer_ts');
217 const list = [];
218 const seen = new Set();
219
220 // Reverse cursor on [peer, created_at] — for each peer, the last
221 // entry in sort order is the newest message. After grabbing it,
222 // advance past the rest of that peer's messages.
223 const req = idx.openCursor(null, 'prev');
224 req.onsuccess = (e) => {
225 const cursor = e.target.result;
226 if (!cursor) {
227 list.sort((a, b) => b.lastTs - a.lastTs);
228 fn(JSON.stringify(list));
229 return;
230 }
231 const dm = cursor.value;
232 if (!seen.has(dm.peer)) {
233 seen.add(dm.peer);
234 list.push({
235 peer: dm.peer,
236 lastMessage: dm.content.slice(0, 80),
237 lastTs: dm.created_at,
238 from: dm.from,
239 });
240 // Skip to the previous peer — advance cursor past all entries
241 // for this peer by setting upper bound to [peer, 0].
242 cursor.continue([dm.peer, 0]);
243 } else {
244 // Still in same peer range (shouldn't happen with continue skip,
245 // but handle gracefully).
246 cursor.continue();
247 }
248 };
249 req.onerror = () => { console.warn('GetConversationList error:', req.error); fn('[]'); };
250 });
251 }
252
253 export function ClearDMsByPeer(peer, fn) {
254 getDB().then(db => {
255 const tx = db.transaction('dms', 'readwrite');
256 const store = tx.objectStore('dms');
257 const idx = store.index('peer_ts');
258 const range = IDBKeyRange.bound([peer, 0], [peer, Infinity]);
259 const req = idx.openCursor(range);
260 req.onsuccess = (e) => {
261 const cursor = e.target.result;
262 if (!cursor) { fn(); return; }
263 cursor.delete();
264 cursor.continue();
265 };
266 req.onerror = () => fn();
267 });
268 }
269
270 export function SetVersion(v) {
271 _expectedVersion = v;
272 }
273