idb.go raw

   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