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