idb.mjs raw
1 // TinyJS Runtime — IndexedDB Bridge
2 // Provides Go-callable IndexedDB operations for event and DM storage.
3
4 const DB_NAME = 'smesh';
5 const DB_VERSION = 2;
6 let _db = null;
7 let _appVersion = '';
8
9 function _openDB(onReady) {
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 ev = db.createObjectStore('events', { keyPath: 'id' });
15 ev.createIndex('kind', 'kind', { unique: false });
16 ev.createIndex('pubkey', 'pubkey', { unique: false });
17 ev.createIndex('created_at', 'created_at', { unique: false });
18 ev.createIndex('kind_created', ['kind', 'created_at'], { unique: false });
19 }
20 if (!db.objectStoreNames.contains('dms')) {
21 const dm = db.createObjectStore('dms', { keyPath: 'id' });
22 dm.createIndex('peer', 'peer', { unique: false });
23 dm.createIndex('created_at', 'created_at', { unique: false });
24 dm.createIndex('peer_created', ['peer', 'created_at'], { unique: false });
25 }
26 if (!db.objectStoreNames.contains('meta')) {
27 db.createObjectStore('meta', { keyPath: 'key' });
28 }
29 };
30 req.onsuccess = (e) => {
31 _db = e.target.result;
32 onReady();
33 };
34 req.onerror = (e) => {
35 console.error('idb: open error:', e.target.error);
36 onReady();
37 };
38 }
39
40 function _checkVersion(fn) {
41 if (!_db || !_appVersion) { fn(); return; }
42 const tx = _db.transaction('meta', 'readonly');
43 const store = tx.objectStore('meta');
44 const req = store.get('version');
45 req.onsuccess = () => {
46 const stored = req.result ? req.result.value : '';
47 if (stored && stored !== _appVersion) {
48 // Version mismatch — flush all data stores.
49 const clearTx = _db.transaction(['events', 'dms', 'meta'], 'readwrite');
50 clearTx.objectStore('events').clear();
51 clearTx.objectStore('dms').clear();
52 clearTx.objectStore('meta').put({ key: 'version', value: _appVersion });
53 clearTx.oncomplete = fn;
54 clearTx.onerror = fn;
55 } else if (!stored) {
56 const writeTx = _db.transaction('meta', 'readwrite');
57 writeTx.objectStore('meta').put({ key: 'version', value: _appVersion });
58 writeTx.oncomplete = fn;
59 writeTx.onerror = fn;
60 } else {
61 fn();
62 }
63 };
64 req.onerror = fn;
65 }
66
67 export function SetVersion(v) {
68 _appVersion = v;
69 }
70
71 export function Open(fn) {
72 _openDB(() => {
73 _checkVersion(() => { if (fn) fn(); });
74 });
75 }
76
77 export function SaveEvent(eventJSON, fn) {
78 if (!_db) { if (fn) fn(false); return; }
79 let ev;
80 try { ev = JSON.parse(eventJSON); } catch(e) { if (fn) fn(false); return; }
81 const tx = _db.transaction('events', 'readwrite');
82 const store = tx.objectStore('events');
83 const check = store.get(ev.id);
84 check.onsuccess = () => {
85 if (check.result) {
86 if (fn) fn(false); // duplicate
87 } else {
88 store.put(ev);
89 tx.oncomplete = () => { if (fn) fn(true); };
90 tx.onerror = () => { if (fn) fn(false); };
91 }
92 };
93 check.onerror = () => { if (fn) fn(false); };
94 }
95
96 export function QueryEvents(filterJSON, fn) {
97 if (!_db) { if (fn) fn('[]'); return; }
98 let filter;
99 try { filter = JSON.parse(filterJSON); } catch(e) { if (fn) fn('[]'); return; }
100 const tx = _db.transaction('events', 'readonly');
101 const store = tx.objectStore('events');
102 const results = [];
103 const limit = filter.limit || 500;
104
105 // If filtering by specific IDs, fetch directly.
106 if (filter.ids && filter.ids.length > 0) {
107 let pending = filter.ids.length;
108 for (const id of filter.ids) {
109 const req = store.get(id);
110 req.onsuccess = () => {
111 if (req.result) results.push(req.result);
112 if (--pending === 0) {
113 if (fn) fn(JSON.stringify(results.slice(0, limit)));
114 }
115 };
116 req.onerror = () => {
117 if (--pending === 0) {
118 if (fn) fn(JSON.stringify(results.slice(0, limit)));
119 }
120 };
121 }
122 return;
123 }
124
125 // Use kind+created_at index when filtering by kinds.
126 let source;
127 if (filter.kinds && filter.kinds.length === 1) {
128 const kind = filter.kinds[0];
129 const idx = store.index('kind_created');
130 const lower = [kind, Number(filter.since) || 0];
131 const upper = [kind, Number(filter.until) || Date.now() / 1000 + 86400];
132 source = idx.openCursor(IDBKeyRange.bound(lower, upper), 'prev');
133 } else {
134 const idx = store.index('created_at');
135 source = idx.openCursor(null, 'prev');
136 }
137
138 source.onsuccess = (e) => {
139 const cursor = e.target.result;
140 if (!cursor || results.length >= limit) {
141 if (fn) fn(JSON.stringify(results));
142 return;
143 }
144 const ev = cursor.value;
145 if (_matchesFilter(ev, filter)) {
146 results.push(ev);
147 }
148 cursor.continue();
149 };
150 source.onerror = () => { if (fn) fn('[]'); };
151 }
152
153 function _matchesFilter(ev, f) {
154 if (f.kinds && f.kinds.length > 0 && !f.kinds.includes(ev.kind)) return false;
155 if (f.authors && f.authors.length > 0 && !f.authors.includes(ev.pubkey)) return false;
156 if (f.since && ev.created_at < f.since) return false;
157 if (f.until && ev.created_at > f.until) return false;
158 // Tag filters (#e, #p, etc.)
159 for (const key of Object.keys(f)) {
160 if (key.startsWith('#') && key.length === 2) {
161 const tag = key[1];
162 const vals = f[key];
163 if (vals && vals.length > 0) {
164 const evTags = (ev.tags || []).filter(t => t[0] === tag).map(t => t[1]);
165 if (!vals.some(v => evTags.includes(v))) return false;
166 }
167 }
168 }
169 return true;
170 }
171
172 export function SaveDM(dmJSON, fn) {
173 if (!_db) { if (fn) fn('error'); return; }
174 let dm;
175 try { dm = JSON.parse(dmJSON); } catch(e) { if (fn) fn('error'); return; }
176 if (!dm.id) { if (fn) fn('error'); return; }
177 const tx = _db.transaction('dms', 'readwrite');
178 const store = tx.objectStore('dms');
179 const check = store.get(dm.id);
180 check.onsuccess = () => {
181 if (check.result) {
182 // Check if this is an upgrade (e.g. decrypted version replacing encrypted).
183 if (dm.content && (!check.result.content || check.result.content !== dm.content)) {
184 store.put(dm);
185 tx.oncomplete = () => { if (fn) fn('upgraded'); };
186 tx.onerror = () => { if (fn) fn('error'); };
187 } else {
188 if (fn) fn('duplicate');
189 }
190 } else {
191 store.put(dm);
192 tx.oncomplete = () => { if (fn) fn('saved'); };
193 tx.onerror = () => { if (fn) fn('error'); };
194 }
195 };
196 check.onerror = () => { if (fn) fn('error'); };
197 }
198
199 export function QueryDMs(peer, limit, until, fn) {
200 if (!_db) { if (fn) fn('[]'); return; }
201 const tx = _db.transaction('dms', 'readonly');
202 const store = tx.objectStore('dms');
203 const idx = store.index('peer_created');
204 const results = [];
205 const max = limit || 50;
206 const upper = Number(until) > 0 ? Number(until) : Date.now() / 1000 + 86400;
207 const range = IDBKeyRange.bound([peer, 0], [peer, upper]);
208 const req = idx.openCursor(range, 'prev');
209 req.onsuccess = (e) => {
210 const cursor = e.target.result;
211 if (!cursor || results.length >= max) {
212 if (fn) fn(JSON.stringify(results));
213 return;
214 }
215 results.push(cursor.value);
216 cursor.continue();
217 };
218 req.onerror = () => { if (fn) fn('[]'); };
219 }
220
221 export function GetConversationList(fn) {
222 if (!_db) { if (fn) fn('[]'); return; }
223 const tx = _db.transaction('dms', 'readonly');
224 const store = tx.objectStore('dms');
225 const convos = new Map(); // peer -> latest DM
226 const req = store.openCursor();
227 req.onsuccess = (e) => {
228 const cursor = e.target.result;
229 if (!cursor) {
230 const list = Array.from(convos.values());
231 list.sort((a, b) => (b.created_at || 0) - (a.created_at || 0));
232 if (fn) fn(JSON.stringify(list));
233 return;
234 }
235 const dm = cursor.value;
236 const peer = dm.peer || '';
237 if (peer) {
238 const existing = convos.get(peer);
239 if (!existing || (dm.created_at || 0) > (existing.created_at || 0)) {
240 convos.set(peer, dm);
241 }
242 }
243 cursor.continue();
244 };
245 req.onerror = () => { if (fn) fn('[]'); };
246 }
247
248 export function ClearDMsByPeer(peer, fn) {
249 if (!_db) { if (fn) fn(); return; }
250 const tx = _db.transaction('dms', 'readwrite');
251 const store = tx.objectStore('dms');
252 const idx = store.index('peer');
253 const req = idx.openCursor(IDBKeyRange.only(peer));
254 req.onsuccess = (e) => {
255 const cursor = e.target.result;
256 if (!cursor) return;
257 cursor.delete();
258 cursor.continue();
259 };
260 tx.oncomplete = () => { if (fn) fn(); };
261 tx.onerror = () => { if (fn) fn(); };
262 }
263