//go:build js && wasm // The browser persistence: one IndexedDB database, one object store, keys out of line. // // This is the write-behind side of the cache, and it exists because the browser has no // synchronous durable store worth trusting. IndexedDB is asynchronous and transactional, // and bolt-like code cannot be written on top of it without turning the wallet's hot path // into a per-write round trip on another thread. So it is not on the hot path: the cache // answers every read and every write from memory, and this file is what the flusher drives // every DefaultFlushInterval. // // Two shapes matter: // // - Load runs exactly once, at open, and blocks the caller until the transaction finishes. // Blocking is safe here because Go's wasm runtime returns to the JS event loop while a // goroutine waits, so the IndexedDB callbacks still run; and it is the one place where // waiting is honest, because a wallet that started before its own documents loaded would // report a zero balance for a funded seed. // - Write applies a whole batch in one readwrite transaction, and reports a failure that // the cache keeps dirty. A batch that half-applied would leave the cache and the durable // state disagreeing, which is the failure mode this design is built to avoid. package store import ( "errors" "fmt" "syscall/js" ) // dbVersion is the IndexedDB schema version. Bumping it is how a migration is spelled. const dbVersion = 1 // objectStore is the one store inside the database. const objectStore = "kv" // IndexedDB is a Persistence over a browser IndexedDB database. // // It is not safe for concurrent use: the cache serialises flushes already, and a second // reader of one database handle would have to be another IndexedDB transaction, which is a // design this package does not need. type IndexedDB struct { db js.Value } // indexDBIDName is the database name a wallet uses unless it is told otherwise. const indexDBIDName = "tinywallet" // OpenIndexedDB opens (or creates) the database and returns it as a Persistence. func OpenIndexedDB(name string) (*IndexedDB, error) { if name == "" { name = indexDBIDName } idb := js.Global().Get("indexedDB") if idb.IsUndefined() || idb.IsNull() { return nil, errors.New("idb: this browser has no IndexedDB") } type reply struct { db js.Value err error } done := make(chan reply, 1) open := idb.Call("open", name, dbVersion) var ( onUpgrade js.Func onSuccess js.Func onError js.Func settled bool ) // settle answers once: an open may report success and then a transaction error, and a // second answer on the channel would be a goroutine nobody reads. settle := func(result reply) { if settled { return } settled = true onUpgrade.Release() onSuccess.Release() onError.Release() done <- result } onUpgrade = js.FuncOf(func(js.Value, []js.Value) any { upgrade := open.Get("result") // The upgrade transaction is the only place a store may be created. if !upgrade.Get("objectStoreNames").Call("contains", objectStore).Bool() { upgrade.Call("createObjectStore", objectStore) } return nil }) onSuccess = js.FuncOf(func(js.Value, []js.Value) any { settle(reply{db: open.Get("result")}) return nil }) onError = js.FuncOf(func(this js.Value, _ []js.Value) any { settle(reply{err: jsError("idb: opening the database", this.Get("target").Get("error"))}) return nil }) // The handler values are installed after the callbacks exist, so a fire before the // handlers are set cannot call a nil js.Func. open.Set("onupgradeneeded", onUpgrade) open.Set("onsuccess", onSuccess) open.Set("onerror", onError) result := <-done if result.err != nil { return nil, result.err } if result.db.IsUndefined() || result.db.IsNull() { return nil, errors.New("idb: the database did not open") } return &IndexedDB{db: result.db}, nil } // Load reads every document in one readonly transaction. func (i *IndexedDB) Load() (map[string][]byte, error) { tx, store, err := i.transaction("readonly") if err != nil { return nil, err } keysRequest := store.Call("getAllKeys") valuesRequest := store.Call("getAll") if err := awaitTransaction(tx); err != nil { return nil, fmt.Errorf("idb: reading: %w", err) } keys := keysRequest.Get("result") if keys.IsUndefined() || keys.Type() != js.TypeObject { return nil, errors.New("idb: the key list is not an array") } values := valuesRequest.Get("result") length := keys.Length() if values.IsUndefined() || values.Type() != js.TypeObject || values.Length() != length { return nil, errors.New("idb: the value list does not match the key list") } out := make(map[string][]byte, length) for index := 0; index < length; index++ { key := keys.Index(index) if key.Type() != js.TypeString { return nil, fmt.Errorf("idb: a key is a %s, not a string", key.Type()) } value, err := toBytes(values.Index(index)) if err != nil { return nil, fmt.Errorf("idb: %q: %w", key.String(), err) } out[key.String()] = value } return out, nil } // Write applies a whole batch in one readwrite transaction. // // The operation list is applied in the order given and the transaction is left to commit: // IndexedDB's own atomicity is the guarantee, so a failure here means nothing was written and // the cache keeps the whole batch dirty. func (i *IndexedDB) Write(batch []Op) error { if len(batch) == 0 { return nil } tx, store, err := i.transaction("readwrite") if err != nil { return err } for _, op := range batch { if op.Delete { store.Call("delete", op.Key) continue } value, err := fromBytes(op.Value) if err != nil { // Abandon the transaction: a batch that cannot be encoded must not half-write. _ = tx.Call("abort") return fmt.Errorf("idb: %q: %w", op.Key, err) } store.Call("put", value, op.Key) } if err := awaitTransaction(tx); err != nil { return fmt.Errorf("idb: writing %d change(s): %w", len(batch), err) } return nil } // transaction opens a transaction over the one object store. func (i *IndexedDB) transaction(mode string) (js.Value, js.Value, error) { if i.db.IsUndefined() || i.db.IsNull() { return js.Value{}, js.Value{}, errors.New("idb: the database is not open") } tx := i.db.Call("transaction", objectStore, mode) if tx.IsUndefined() || tx.IsNull() { return js.Value{}, js.Value{}, errors.New("idb: the transaction did not start") } return tx, tx.Call("objectStore", objectStore), nil } // errAborted is what a transaction reports when it did not commit. var errAborted = errors.New("the transaction was aborted") // awaitTransaction blocks until the transaction commits, fails, or aborts. func awaitTransaction(tx js.Value) error { done := make(chan error, 1) var ( onComplete js.Func onError js.Func onAbort js.Func settled bool ) // A transaction reports one outcome: a request error also aborts and can also raise an // abort event, and answering twice would leave a goroutine blocked on nothing. settle := func(err error) { if settled { return } settled = true onComplete.Release() onError.Release() onAbort.Release() done <- err } onComplete = js.FuncOf(func(js.Value, []js.Value) any { settle(nil) return nil }) onError = js.FuncOf(func(this js.Value, _ []js.Value) any { settle(jsError("indexeddb", this.Get("target").Get("error"))) return nil }) onAbort = js.FuncOf(func(this js.Value, _ []js.Value) any { target := this.Get("target") err := errAborted if !target.IsUndefined() && !target.IsNull() { if got := target.Get("error"); !got.IsUndefined() && !got.IsNull() { err = jsError("indexeddb", got) } } settle(err) return nil }) tx.Set("oncomplete", onComplete) tx.Set("onerror", onError) tx.Set("onabort", onAbort) return <-done } // fromBytes copies a value into a Uint8Array for IndexedDB. // // The copy is deliberate: a view over Go memory would be read by the database after the // goroutine moved on, and the bytes are small enough that copying once per flush is nothing. func fromBytes(value []byte) (js.Value, error) { array := js.Global().Get("Uint8Array").New(len(value)) if len(value) > 0 { _ = js.CopyBytesToJS(array, value) } return array, nil } // toBytes copies a stored value back into Go. func toBytes(value js.Value) ([]byte, error) { if value.IsNull() || value.IsUndefined() { return nil, errors.New("the stored value is empty") } typed := js.Global().Get("Uint8Array") if !value.InstanceOf(typed) { return nil, fmt.Errorf("the stored value is a %s, not a Uint8Array", value.Type()) } out := make([]byte, value.Length()) js.CopyBytesToGo(out, value) return out, nil } // jsError turns a JS error value into a Go error, keeping the browser's message. func jsError(what string, value js.Value) error { if value.IsUndefined() || value.IsNull() { return fmt.Errorf("%s: no error was reported", what) } if message := value.Get("message"); message.Type() == js.TypeString { return fmt.Errorf("%s: %s", what, message.String()) } return fmt.Errorf("%s: %s", what, value.String()) }