1 //go:build js && wasm
2 3 // The browser persistence: one IndexedDB database, one object store, keys out of line.
4 //
5 // This is the write-behind side of the cache, and it exists because the browser has no
6 // synchronous durable store worth trusting. IndexedDB is asynchronous and transactional,
7 // and bolt-like code cannot be written on top of it without turning the wallet's hot path
8 // into a per-write round trip on another thread. So it is not on the hot path: the cache
9 // answers every read and every write from memory, and this file is what the flusher drives
10 // every DefaultFlushInterval.
11 //
12 // Two shapes matter:
13 //
14 // - Load runs exactly once, at open, and blocks the caller until the transaction finishes.
15 // Blocking is safe here because Go's wasm runtime returns to the JS event loop while a
16 // goroutine waits, so the IndexedDB callbacks still run; and it is the one place where
17 // waiting is honest, because a wallet that started before its own documents loaded would
18 // report a zero balance for a funded seed.
19 // - Write applies a whole batch in one readwrite transaction, and reports a failure that
20 // the cache keeps dirty. A batch that half-applied would leave the cache and the durable
21 // state disagreeing, which is the failure mode this design is built to avoid.
22 package store
23 24 import (
25 "errors"
26 "fmt"
27 "syscall/js"
28 )
29 30 // dbVersion is the IndexedDB schema version. Bumping it is how a migration is spelled.
31 const dbVersion = 1
32 33 // objectStore is the one store inside the database.
34 const objectStore = "kv"
35 36 // IndexedDB is a Persistence over a browser IndexedDB database.
37 //
38 // It is not safe for concurrent use: the cache serialises flushes already, and a second
39 // reader of one database handle would have to be another IndexedDB transaction, which is a
40 // design this package does not need.
41 type IndexedDB struct {
42 db js.Value
43 }
44 45 // indexDBIDName is the database name a wallet uses unless it is told otherwise.
46 const indexDBIDName = "tinywallet"
47 48 // OpenIndexedDB opens (or creates) the database and returns it as a Persistence.
49 func OpenIndexedDB(name string) (*IndexedDB, error) {
50 if name == "" {
51 name = indexDBIDName
52 }
53 54 idb := js.Global().Get("indexedDB")
55 56 if idb.IsUndefined() || idb.IsNull() {
57 return nil, errors.New("idb: this browser has no IndexedDB")
58 }
59 60 type reply struct {
61 db js.Value
62 err error
63 }
64 65 done := make(chan reply, 1)
66 open := idb.Call("open", name, dbVersion)
67 68 var (
69 onUpgrade js.Func
70 onSuccess js.Func
71 onError js.Func
72 settled bool
73 )
74 75 // settle answers once: an open may report success and then a transaction error, and a
76 // second answer on the channel would be a goroutine nobody reads.
77 settle := func(result reply) {
78 if settled {
79 return
80 }
81 82 settled = true
83 84 onUpgrade.Release()
85 onSuccess.Release()
86 onError.Release()
87 88 done <- result
89 }
90 91 onUpgrade = js.FuncOf(func(js.Value, []js.Value) any {
92 upgrade := open.Get("result")
93 94 // The upgrade transaction is the only place a store may be created.
95 if !upgrade.Get("objectStoreNames").Call("contains", objectStore).Bool() {
96 upgrade.Call("createObjectStore", objectStore)
97 }
98 99 return nil
100 })
101 102 onSuccess = js.FuncOf(func(js.Value, []js.Value) any {
103 settle(reply{db: open.Get("result")})
104 105 return nil
106 })
107 108 onError = js.FuncOf(func(this js.Value, _ []js.Value) any {
109 settle(reply{err: jsError("idb: opening the database", this.Get("target").Get("error"))})
110 111 return nil
112 })
113 114 // The handler values are installed after the callbacks exist, so a fire before the
115 // handlers are set cannot call a nil js.Func.
116 open.Set("onupgradeneeded", onUpgrade)
117 open.Set("onsuccess", onSuccess)
118 open.Set("onerror", onError)
119 120 result := <-done
121 122 if result.err != nil {
123 return nil, result.err
124 }
125 126 if result.db.IsUndefined() || result.db.IsNull() {
127 return nil, errors.New("idb: the database did not open")
128 }
129 130 return &IndexedDB{db: result.db}, nil
131 }
132 133 // Load reads every document in one readonly transaction.
134 func (i *IndexedDB) Load() (map[string][]byte, error) {
135 tx, store, err := i.transaction("readonly")
136 137 if err != nil {
138 return nil, err
139 }
140 141 keysRequest := store.Call("getAllKeys")
142 valuesRequest := store.Call("getAll")
143 144 if err := awaitTransaction(tx); err != nil {
145 return nil, fmt.Errorf("idb: reading: %w", err)
146 }
147 148 keys := keysRequest.Get("result")
149 150 if keys.IsUndefined() || keys.Type() != js.TypeObject {
151 return nil, errors.New("idb: the key list is not an array")
152 }
153 154 values := valuesRequest.Get("result")
155 156 length := keys.Length()
157 158 if values.IsUndefined() || values.Type() != js.TypeObject || values.Length() != length {
159 return nil, errors.New("idb: the value list does not match the key list")
160 }
161 162 out := make(map[string][]byte, length)
163 164 for index := 0; index < length; index++ {
165 key := keys.Index(index)
166 167 if key.Type() != js.TypeString {
168 return nil, fmt.Errorf("idb: a key is a %s, not a string", key.Type())
169 }
170 171 value, err := toBytes(values.Index(index))
172 173 if err != nil {
174 return nil, fmt.Errorf("idb: %q: %w", key.String(), err)
175 }
176 177 out[key.String()] = value
178 }
179 180 return out, nil
181 }
182 183 // Write applies a whole batch in one readwrite transaction.
184 //
185 // The operation list is applied in the order given and the transaction is left to commit:
186 // IndexedDB's own atomicity is the guarantee, so a failure here means nothing was written and
187 // the cache keeps the whole batch dirty.
188 func (i *IndexedDB) Write(batch []Op) error {
189 if len(batch) == 0 {
190 return nil
191 }
192 193 tx, store, err := i.transaction("readwrite")
194 195 if err != nil {
196 return err
197 }
198 199 for _, op := range batch {
200 if op.Delete {
201 store.Call("delete", op.Key)
202 203 continue
204 }
205 206 value, err := fromBytes(op.Value)
207 208 if err != nil {
209 // Abandon the transaction: a batch that cannot be encoded must not half-write.
210 _ = tx.Call("abort")
211 212 return fmt.Errorf("idb: %q: %w", op.Key, err)
213 }
214 215 store.Call("put", value, op.Key)
216 }
217 218 if err := awaitTransaction(tx); err != nil {
219 return fmt.Errorf("idb: writing %d change(s): %w", len(batch), err)
220 }
221 222 return nil
223 }
224 225 // transaction opens a transaction over the one object store.
226 func (i *IndexedDB) transaction(mode string) (js.Value, js.Value, error) {
227 if i.db.IsUndefined() || i.db.IsNull() {
228 return js.Value{}, js.Value{}, errors.New("idb: the database is not open")
229 }
230 231 tx := i.db.Call("transaction", objectStore, mode)
232 233 if tx.IsUndefined() || tx.IsNull() {
234 return js.Value{}, js.Value{}, errors.New("idb: the transaction did not start")
235 }
236 237 return tx, tx.Call("objectStore", objectStore), nil
238 }
239 240 // errAborted is what a transaction reports when it did not commit.
241 var errAborted = errors.New("the transaction was aborted")
242 243 // awaitTransaction blocks until the transaction commits, fails, or aborts.
244 func awaitTransaction(tx js.Value) error {
245 done := make(chan error, 1)
246 247 var (
248 onComplete js.Func
249 onError js.Func
250 onAbort js.Func
251 settled bool
252 )
253 254 // A transaction reports one outcome: a request error also aborts and can also raise an
255 // abort event, and answering twice would leave a goroutine blocked on nothing.
256 settle := func(err error) {
257 if settled {
258 return
259 }
260 261 settled = true
262 263 onComplete.Release()
264 onError.Release()
265 onAbort.Release()
266 267 done <- err
268 }
269 270 onComplete = js.FuncOf(func(js.Value, []js.Value) any {
271 settle(nil)
272 273 return nil
274 })
275 276 onError = js.FuncOf(func(this js.Value, _ []js.Value) any {
277 settle(jsError("indexeddb", this.Get("target").Get("error")))
278 279 return nil
280 })
281 282 onAbort = js.FuncOf(func(this js.Value, _ []js.Value) any {
283 target := this.Get("target")
284 err := errAborted
285 286 if !target.IsUndefined() && !target.IsNull() {
287 if got := target.Get("error"); !got.IsUndefined() && !got.IsNull() {
288 err = jsError("indexeddb", got)
289 }
290 }
291 292 settle(err)
293 294 return nil
295 })
296 297 tx.Set("oncomplete", onComplete)
298 tx.Set("onerror", onError)
299 tx.Set("onabort", onAbort)
300 301 return <-done
302 }
303 304 // fromBytes copies a value into a Uint8Array for IndexedDB.
305 //
306 // The copy is deliberate: a view over Go memory would be read by the database after the
307 // goroutine moved on, and the bytes are small enough that copying once per flush is nothing.
308 func fromBytes(value []byte) (js.Value, error) {
309 array := js.Global().Get("Uint8Array").New(len(value))
310 311 if len(value) > 0 {
312 _ = js.CopyBytesToJS(array, value)
313 }
314 315 return array, nil
316 }
317 318 // toBytes copies a stored value back into Go.
319 func toBytes(value js.Value) ([]byte, error) {
320 if value.IsNull() || value.IsUndefined() {
321 return nil, errors.New("the stored value is empty")
322 }
323 324 typed := js.Global().Get("Uint8Array")
325 326 if !value.InstanceOf(typed) {
327 return nil, fmt.Errorf("the stored value is a %s, not a Uint8Array", value.Type())
328 }
329 330 out := make([]byte, value.Length())
331 js.CopyBytesToGo(out, value)
332 333 return out, nil
334 }
335 336 // jsError turns a JS error value into a Go error, keeping the browser's message.
337 func jsError(what string, value js.Value) error {
338 if value.IsUndefined() || value.IsNull() {
339 return fmt.Errorf("%s: no error was reported", what)
340 }
341 342 if message := value.Get("message"); message.Type() == js.TypeString {
343 return fmt.Errorf("%s: %s", what, message.String())
344 }
345 346 return fmt.Errorf("%s: %s", what, value.String())
347 }
348