diff --git a/pkg/filesystem/classic.go b/pkg/filesystem/classic.go index 38a741a..9b2926a 100644 --- a/pkg/filesystem/classic.go +++ b/pkg/filesystem/classic.go @@ -13,9 +13,7 @@ import ( const DirName string = ".git-calendar-data" -// easy as that you bozo -// -// Returns a FS starting from users home directory +// Returns a FS starting from users home directory. func GetFS() (billy.Filesystem, error) { // get user home dir home, err := os.UserHomeDir() diff --git a/pkg/filesystem/wasm.go b/pkg/filesystem/wasm.go index c5245af..d19f86e 100644 --- a/pkg/filesystem/wasm.go +++ b/pkg/filesystem/wasm.go @@ -3,36 +3,13 @@ package filesystem import ( - "errors" - "fmt" - "syscall/js" - - "github.com/git-calendar/core/pkg/opfs" + "github.com/git-calendar/core/pkg/idb" "github.com/go-git/go-billy/v5" ) -const DirName = "git-calendar-data" +const DirName = "git-calendar-data" // the storeName for IndexedDB +// Returns a FS inside IndexedDB. func GetFS() (billy.Filesystem, error) { - // gets the handle from js window.opfsRootHandle - rootHandle := js.Global().Get("opfsRootHandle") - if rootHandle.IsUndefined() { - return nil, errors.New("opfsRootHandle not initialized") - } - - // create OPFS rooted at / - fs := opfs.New(rootHandle) - - // ensure directory exists - if err := fs.MkdirAll(DirName, 0o755); err != nil { - return nil, fmt.Errorf("failed to create dir: %w", err) - } - - // chroot into the directory - chrooted, err := fs.Chroot(DirName) - if err != nil { - return nil, fmt.Errorf("failed to chroot: %w", err) - } - - return chrooted, nil + return idb.New(DirName, 1) } diff --git a/pkg/idb/db.go b/pkg/idb/db.go new file mode 100644 index 0000000..20253a3 --- /dev/null +++ b/pkg/idb/db.go @@ -0,0 +1,470 @@ +//go:build js && wasm + +// Package idb implements the billy.Filesystem interface backed by IndexedDB store. +// It is only usable in a js/wasm build targeting a browser environment. +// https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API +package idb + +import ( + "errors" + "fmt" + "os" + "path" + "strings" + "syscall/js" + "time" + + "github.com/go-git/go-billy/v5" +) + +type IndexedDB struct { + name string // The DB name. + jsDB js.Value // JS IDBDatabase object (https://developer.mozilla.org/en-US/docs/Web/API/IDBDatabase) + root string // The current abosolute root. +} + +const ( + contentStoreName = "content" + infoStoreName = "info" +) + +func New(name string, version int) (*IndexedDB, error) { + jsIdb := js.Global().Get("indexedDB") + if jsIdb.IsUndefined() { + return nil, fmt.Errorf("indexedDB not supported") + } + req := jsIdb.Call("open", name, version) + + dbValue, err := awaitOpen(req, func(db js.Value) { + storeNames := db.Get("objectStoreNames") + if !storeNames.Call("contains", contentStoreName).Bool() { + db.Call("createObjectStore", contentStoreName) // create "content" store + } + if !storeNames.Call("contains", infoStoreName).Bool() { + db.Call("createObjectStore", infoStoreName) // create "info" store + } + }) + if err != nil { + return nil, err + } + + idb := IndexedDB{ + name: name, + jsDB: dbValue, + root: "/", + } + + // check if root exists + tx := NewTx() + rootReq := tx.Get(infoStoreName, "/") + _ = tx.Commit(idb.jsDB) + + if !rootReq.Result().Truthy() { + info := IDBFileInfo{ + name: "/", + modTime: time.Now(), + mode: os.ModeDir | 0o755, + } + + txInit := NewTx() + txInit.Put(infoStoreName, "/", info.toJS()) + if err := txInit.Commit(idb.jsDB); err != nil { + return nil, err + } + } + + return &idb, nil +} + +func (idb *IndexedDB) Create(filename string) (billy.File, error) { + key := idb.absolutePath(filename) + + tx := NewTx() + tx.Delete(contentStoreName, key) // clear old content if any + tx.Put(infoStoreName, key, (&IDBFileInfo{ + name: path.Base(filename), + size: 0, + modTime: time.Now(), + mode: 0o666, + }).toJS()) + if err := tx.Commit(idb.jsDB); err != nil { + return nil, fmt.Errorf("failed to create file %s in idb: %w", filename, err) + } + + return &IDBFile{ + fs: idb, + key: key, + relPath: filename, + offset: 0, + }, nil +} + +func (idb *IndexedDB) Open(filename string) (billy.File, error) { + key := idb.absolutePath(filename) + + info, err := idb.Stat(filename) + exists := err == nil + if !exists { + return nil, os.ErrNotExist + } + if info.IsDir() { + return nil, fmt.Errorf("cannot open directory: %s", filename) + } + + return &IDBFile{ + fs: idb, + key: key, + relPath: filename, + offset: 0, + }, nil +} + +func (idb *IndexedDB) OpenFile(filename string, flag int, perm os.FileMode) (billy.File, error) { + key := idb.absolutePath(filename) + create := flag&os.O_CREATE != 0 + + info, err := idb.Stat(filename) + exists := err == nil + + if exists && info.IsDir() { + return nil, fmt.Errorf("cannot open directory: %s", filename) + } + + if !exists && !create { + return nil, os.ErrNotExist + } + + if !exists && create { + // create all parent dirs + if err := idb.MkdirAll(path.Dir(filename), 0o755); err != nil { + return nil, err + } + + // create new file + newInfo := IDBFileInfo{ + name: path.Base(filename), + modTime: time.Now(), + mode: perm, + } + + tx := NewTx() + tx.Put(infoStoreName, key, newInfo.toJS()) + if err := tx.Commit(idb.jsDB); err != nil { + return nil, err + } + } + + file := IDBFile{ + fs: idb, + key: key, + relPath: filename, + offset: 0, + } + if err := idb.applyFlags(&file, flag); err != nil { + return nil, err + } + return &file, nil +} + +func (idb *IndexedDB) Stat(name string) (os.FileInfo, error) { + tx := NewTx() + req := tx.Get(infoStoreName, idb.absolutePath(name)) + if err := tx.Commit(idb.jsDB); err != nil { + return nil, err + } + + result := req.Result() + if !result.Truthy() { + return nil, os.ErrNotExist + } + + return FileInfoFromJS(result), nil +} + +func (idb *IndexedDB) Rename(oldpath, newpath string) error { + // check source exists + info, err := idb.Stat(oldpath) + if err != nil { + return err + } + + // fail if target exists + if _, err := idb.Stat(newpath); err == nil { + return errors.New("target already exists") + } else if !os.IsNotExist(err) { + return err + } + + // call one of the helpers + if info.IsDir() { + return idb.renameDir(oldpath, newpath) + } + return idb.renameFile(oldpath, newpath) +} + +func (idb *IndexedDB) Remove(name string) error { + info, err := idb.Stat(name) + if err != nil { + return os.ErrNotExist + } + + key := idb.absolutePath(name) + + if !info.IsDir() { + tx := NewTx() + tx.Delete(infoStoreName, key) + tx.Delete(contentStoreName, key) + if err := tx.Commit(idb.jsDB); err != nil { + return err + } + } else { + entries, err := idb.ReadDir(name) + if err != nil { + return err + } + if len(entries) != 0 { + return errors.New("not empty") + } + + tx := NewTx() + tx.Delete(infoStoreName, key) + if err := tx.Commit(idb.jsDB); err != nil { + return err + } + } + + return nil +} + +func (idb *IndexedDB) Join(elem ...string) string { + return path.Join(elem...) +} + +func (idb *IndexedDB) MkdirAll(p string, perm os.FileMode) error { + parts := strings.Split(path.Clean(p), "/") + var currentPath string = idb.root + + tx := NewTx() + + for _, part := range parts { + if part == "." || part == "" { + continue + } + + currentPath = idb.Join(currentPath, part) + info := IDBFileInfo{ + name: part, + modTime: time.Now(), + mode: os.ModeDir | perm, + } + + tx.Put(infoStoreName, currentPath, info.toJS()) + } + + return tx.Commit(idb.jsDB) +} + +func (idb *IndexedDB) ReadDir(p string) ([]os.FileInfo, error) { + fullpath := idb.absolutePath(p) + prefix := strings.TrimSuffix(fullpath, "/") + "/" + + rangeObj := js.Global().Get("IDBKeyRange").Call( + "bound", + fullpath, + fullpath+"\uffff", + ) + + txKeys := NewTx() + keysReq := txKeys.GetAllKeys(infoStoreName, rangeObj) + if err := txKeys.Commit(idb.jsDB); err != nil { + return nil, err + } + + result := keysReq.Result() + if result.IsNull() || result.IsUndefined() { + return nil, os.ErrNotExist + } + + length := result.Length() + keys := make([]string, 0, length) + for idx := range length { + key := result.Index(idx).String() + if key == fullpath { + continue + } + if !strings.HasPrefix(key, prefix) { + continue + } + rest := key[len(prefix):] + if strings.Contains(rest, "/") { + continue + } + + keys = append(keys, key) + } + + txInfos := NewTx() + infoReqs := make([]interface{ Result() js.Value }, len(keys)) + + for idx, key := range keys { + infoReqs[idx] = txInfos.Get(infoStoreName, key) + } + + if err := txInfos.Commit(idb.jsDB); err != nil { + return nil, err + } + + files := make([]os.FileInfo, 0, len(keys)) + for idx := range keys { + files = append(files, FileInfoFromJS(infoReqs[idx].Result())) + } + + return files, nil +} + +func (idb *IndexedDB) TempFile(dir, prefix string) (billy.File, error) { + filename := prefix + randString(10) + return idb.Create(idb.Join(dir, filename)) +} + +func (idb *IndexedDB) Lstat(filename string) (os.FileInfo, error) { + return idb.Stat(filename) // Lstat is same as Stat, but if the file is a symbolic link, it doesn't resolve it +} + +func (idb *IndexedDB) Symlink(target, link string) error { + return billy.ErrNotSupported +} + +func (idb *IndexedDB) Readlink(link string) (string, error) { + return "", billy.ErrNotSupported +} + +func (idb *IndexedDB) Chroot(path string) (billy.Filesystem, error) { + // make sure it exists + if err := idb.MkdirAll(path, 0o777); err != nil { + return nil, err + } + // return a whole new instance + return &IndexedDB{ + name: idb.name, + jsDB: idb.jsDB, + root: idb.absolutePath(path), + }, nil +} + +func (idb *IndexedDB) Root() string { + return idb.root +} + +// ----- HELPERS ----- + +func (idb *IndexedDB) renameFile(oldpath, newpath string) error { + txRead := NewTx() + + oldFull := idb.absolutePath(oldpath) + newFull := idb.absolutePath(newpath) + if oldFull == newFull { + return nil + } + + oldInfo := txRead.Get(infoStoreName, oldFull) + oldContent := txRead.Get(contentStoreName, oldFull) + newInfo := txRead.Get(infoStoreName, newFull) + + // execute reads + if err := txRead.Commit(idb.jsDB); err != nil { + return fmt.Errorf("rename failed during read: %w", err) + } + + if !oldInfo.Result().Truthy() { + return os.ErrNotExist + } + if newInfo.Result().Truthy() { + return os.ErrExist + } + + info := FileInfoFromJS(oldInfo.Result()) + info.name = path.Base(newpath) + info.modTime = time.Now() + + txWrite := NewTx() + + txWrite.Put(infoStoreName, newFull, info.toJS()) + txWrite.Put(contentStoreName, newFull, oldContent.Result()) + txWrite.Delete(infoStoreName, oldFull) + txWrite.Delete(contentStoreName, oldFull) + + if err := txWrite.Commit(idb.jsDB); err != nil { + return fmt.Errorf("rename failed during write: %w", err) + } + + return nil +} + +func (idb *IndexedDB) renameDir(oldpath, newpath string) error { + oldFull := idb.absolutePath(oldpath) + newFull := idb.absolutePath(newpath) + + // create new dir + txRead := NewTx() + oldInfoReq := txRead.Get(infoStoreName, oldFull) + if err := txRead.Commit(idb.jsDB); err != nil { + return err + } + + txWrite := NewTx() + txWrite.Put(infoStoreName, newFull, oldInfoReq.Result()) + if err := txWrite.Commit(idb.jsDB); err != nil { + return err + } + + // list children + children, err := idb.ReadDir(oldpath) + if err != nil { + return err + } + + for _, child := range children { + oldChild := path.Join(oldFull, child.Name()) + newChild := path.Join(newFull, child.Name()) + + if child.IsDir() { + if err := idb.renameDir(oldChild, newChild); err != nil { + return err + } + } else { + if err := idb.renameFile(oldChild, newChild); err != nil { + return err + } + } + } + + // remove old dir (now empty) + return idb.Remove(oldpath) +} + +// Applies the O_TRUNC and O_APPEND flags to a file. +func (idb *IndexedDB) applyFlags(f *IDBFile, flag int) error { + if flag&os.O_TRUNC != 0 { + // truncate the file and then return it empty + if err := f.Truncate(0); err != nil { + return fmt.Errorf("failed to truncate file: %w", err) + } + } + + if flag&os.O_APPEND != 0 { + // prepare the file for appending + info, err := idb.Stat(f.relPath) + if err != nil { + return fmt.Errorf("failed to stat file: %w", err) + } + f.offset = info.Size() // set the offset to the end so that future Write() calls append + } + + return nil +} + +func (idb *IndexedDB) absolutePath(p string) string { + p = path.Clean(p) + return path.Join(idb.root, p) +} diff --git a/pkg/idb/file.go b/pkg/idb/file.go new file mode 100644 index 0000000..f65ab13 --- /dev/null +++ b/pkg/idb/file.go @@ -0,0 +1,189 @@ +//go:build js && wasm + +package idb + +import ( + "fmt" + "io" + "syscall/js" + "time" +) + +type IDBFile struct { + fs *IndexedDB // A reference to it's fs. + key string // The key used in IndexedDB (absolute filepath). + relPath string // Path relative to fs. + offset int64 // Current offset in bytes. +} + +func (f *IDBFile) Name() string { + return f.relPath // returns the filepath RELATIVE to current fs root +} + +func (f *IDBFile) Read(p []byte) (int, error) { + n, err := f.ReadAt(p, f.offset) + f.offset += int64(n) + return n, err +} + +func (f *IDBFile) Write(p []byte) (int, error) { + n, err := f.WriteAt(p, f.offset) + f.offset += int64(n) + return n, err +} + +func (f *IDBFile) ReadAt(p []byte, off int64) (x int, err error) { + tx := NewTx() + req := tx.Get(contentStoreName, f.key) + + if err := tx.Commit(f.fs.jsDB); err != nil { + return 0, err + } + + existingVal := req.Result() + if !existingVal.Truthy() { + return 0, io.EOF + } + + length := existingVal.Get("length").Int() + if int(off) >= length { + return 0, io.EOF + } + + sub := existingVal.Call("subarray", off) + n := js.CopyBytesToGo(p, sub) + + if n < len(p) { + return n, io.EOF + } + return n, nil +} + +func (f *IDBFile) WriteAt(p []byte, off int64) (s int, err error) { + // read existing data and info + txRead := NewTx() + contentReq := txRead.Get(contentStoreName, f.key) + infoReq := txRead.Get(infoStoreName, f.key) + if err := txRead.Commit(f.fs.jsDB); err != nil { + return 0, err + } + existingVal := contentReq.Result() + + // prepare buffer + var buffer []byte + if existingVal.Truthy() { + end := int(off) + len(p) + size := max(existingVal.Length(), end) + buffer = make([]byte, size) + js.CopyBytesToGo(buffer, existingVal) + } else { + end := int(off) + len(p) + buffer = make([]byte, end) + } + + // write into buffer at offset + copy(buffer[off:], p) + + // convert to JS array + jsBuf := js.Global().Get("Uint8Array").New(len(buffer)) + js.CopyBytesToJS(jsBuf, buffer) + + // update FileInfo + info := FileInfoFromJS(infoReq.Result()) + info.size = int64(len(buffer)) // update size + info.modTime = time.Now() // update modification time + + // store Content and Info in a fresh transaction + txWrite := NewTx() + txWrite.Put(contentStoreName, f.key, jsBuf) + txWrite.Put(infoStoreName, f.key, info.toJS()) + if err := txWrite.Commit(f.fs.jsDB); err != nil { + return 0, err + } + + return len(p), nil +} + +func (f *IDBFile) Seek(offset int64, whence int) (int64, error) { + var base int64 + + switch whence { + case io.SeekStart: + base = 0 + + case io.SeekCurrent: + base = f.offset + + case io.SeekEnd: + stat, err := f.fs.Stat(f.relPath) + if err != nil { + return 0, err + } + base = stat.Size() + + default: + return 0, fmt.Errorf("invalid whence") + } + + newOffset := base + offset + if newOffset < 0 { + return 0, fmt.Errorf("negative position") + } + + f.offset = newOffset + return f.offset, nil +} + +func (f *IDBFile) Close() error { + return nil +} + +func (f *IDBFile) Lock() error { + return nil +} + +func (f *IDBFile) Unlock() error { + return nil +} + +func (f *IDBFile) Truncate(size int64) error { + txRead := NewTx() + contentReq := txRead.Get(contentStoreName, f.key) + infoReq := txRead.Get(infoStoreName, f.key) + + if err := txRead.Commit(f.fs.jsDB); err != nil { + return err + } + + existingVal := contentReq.Result() + var data []byte + if existingVal.Truthy() { + data = make([]byte, existingVal.Length()) + js.CopyBytesToGo(data, existingVal) + } + + if int64(len(data)) > size { + data = data[:size] + } else if int64(len(data)) < size { + newBuf := make([]byte, size) + copy(newBuf, data) + data = newBuf + } + + jsBuf := js.Global().Get("Uint8Array").New(len(data)) + js.CopyBytesToJS(jsBuf, data) + + info := FileInfoFromJS(infoReq.Result()) + info.size = size + info.modTime = time.Now() + + txWrite := NewTx() + txWrite.Put(contentStoreName, f.key, jsBuf) + txWrite.Put(infoStoreName, f.key, info.toJS()) + + if err := txWrite.Commit(f.fs.jsDB); err != nil { + return err + } + + return nil +} diff --git a/pkg/idb/file_info.go b/pkg/idb/file_info.go new file mode 100644 index 0000000..341e68a --- /dev/null +++ b/pkg/idb/file_info.go @@ -0,0 +1,45 @@ +//go:build js && wasm + +package idb + +import ( + "io/fs" + "os" + "syscall/js" + "time" +) + +type IDBFileInfo struct { + name string + size int64 + modTime time.Time + mode os.FileMode +} + +func (fi *IDBFileInfo) Name() string { return fi.name } +func (fi *IDBFileInfo) Size() int64 { return fi.size } +func (fi *IDBFileInfo) ModTime() time.Time { return fi.modTime } +func (fi *IDBFileInfo) Sys() any { return nil } +func (fi *IDBFileInfo) Mode() os.FileMode { return fi.mode } +func (fi *IDBFileInfo) Type() fs.FileMode { return fi.Mode().Type() } +func (fi *IDBFileInfo) IsDir() bool { return fi.mode.IsDir() } + +// Converts the file info to a JS Object. +func (fi *IDBFileInfo) toJS() js.Value { + obj := js.Global().Get("Object").New() + obj.Set("name", fi.name) + obj.Set("size", fi.size) + obj.Set("mod_time", fi.modTime.UnixMilli()) + obj.Set("mode", int(fi.mode)) + return obj +} + +// Converts a JS Object into a IDBFileInfo struct. +func FileInfoFromJS(jsVal js.Value) *IDBFileInfo { + return &IDBFileInfo{ + name: jsVal.Get("name").String(), + size: int64(jsVal.Get("size").Int()), + modTime: time.UnixMilli(int64(jsVal.Get("mod_time").Int())), + mode: os.FileMode(jsVal.Get("mode").Int()), + } +} diff --git a/pkg/idb/helpers.go b/pkg/idb/helpers.go new file mode 100644 index 0000000..7fbe15c --- /dev/null +++ b/pkg/idb/helpers.go @@ -0,0 +1,57 @@ +//go:build js && wasm + +package idb + +import ( + "fmt" + "math/rand/v2" + "syscall/js" +) + +// onUpgrade is called if db version is higher or DB does not exist. It's like custom migration method. +func awaitOpen(req js.Value, onUpgrade func(db js.Value)) (js.Value, error) { + resultCh := make(chan js.Value, 1) + errCh := make(chan error, 1) + + success := js.FuncOf(func(this js.Value, args []js.Value) any { + resultCh <- req.Get("result") + return nil + }) + + fail := js.FuncOf(func(this js.Value, args []js.Value) any { + errObj := args[0].Get("error") + errCh <- fmt.Errorf("failed to open indexeddb: %s", errObj.String()) + return nil + }) + + upgrade := js.FuncOf(func(this js.Value, args []js.Value) any { + db := req.Get("result") + onUpgrade(db) + return nil + }) + + req.Set("onsuccess", success) + req.Set("onerror", fail) + req.Set("onupgradeneeded", upgrade) // version is higher or DB does not exist + + defer success.Release() + defer fail.Release() + defer upgrade.Release() + + select { + case err := <-errCh: + return js.Null(), err + case res := <-resultCh: + return res, nil + } +} + +const letters string = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + +func randString(length int) string { + b := make([]byte, length) + for i := range b { + b[i] = letters[rand.IntN(len(letters))] + } + return string(b) +} diff --git a/pkg/idb/transaction.go b/pkg/idb/transaction.go new file mode 100644 index 0000000..d464e85 --- /dev/null +++ b/pkg/idb/transaction.go @@ -0,0 +1,191 @@ +//go:build js && wasm + +package idb + +import ( + "fmt" + "syscall/js" + "time" +) + +const reqTimeout = 2 * time.Second // should be more than enough + +type Transaction struct { + pending []*Operation +} + +type opType int + +const ( + opGet opType = iota + opPut + opDelete + opGetAllKeys +) + +type Operation struct { + typee opType + store string + key string + value js.Value + + result js.Value + err error + + done chan int +} + +// Creates a transaction-like interface. The real JS IDB tx is created and commited inside Tx.Commit(). +func NewTx() *Transaction { + return &Transaction{ + pending: make([]*Operation, 0), + } +} + +// JS: store.get(key) +func (tx *Transaction) Get(store, key string) *Operation { + op := &Operation{ + typee: opGet, + store: store, + key: key, + done: make(chan int, 1), + } + tx.pending = append(tx.pending, op) + return op +} + +// JS: store.delete(key) +func (tx *Transaction) Delete(store, key string) *Operation { + op := &Operation{ + typee: opDelete, + store: store, + key: key, + done: make(chan int, 1), + } + tx.pending = append(tx.pending, op) + return op +} + +// JS: store.put(value, key) +func (tx *Transaction) Put(store, key string, value js.Value) *Operation { + op := &Operation{ + typee: opPut, + store: store, + key: key, + value: value, + done: make(chan int, 1), + } + tx.pending = append(tx.pending, op) + return op +} + +// JS: store.getAllKeys(query) +func (tx *Transaction) GetAllKeys(store string, query js.Value) *Operation { + op := &Operation{ + typee: opGetAllKeys, + store: store, + value: query, + done: make(chan int, 1), + } + tx.pending = append(tx.pending, op) + return op +} + +// It creates the real JS IDB tx, runs all the queued request/queries and waits for them to finish. +func (tx *Transaction) Commit(db js.Value) error { + if len(tx.pending) == 0 { + return nil + } + + // duplicate store names + storeSet := make(map[string]bool) + stores := js.Global().Get("Array").New() + for _, op := range tx.pending { + if !storeSet[op.store] { + stores.Call("push", op.store) + storeSet[op.store] = true + } + } + + // create the transaction + idbTx := db.Call("transaction", stores, "readwrite") // TODO: not only readwrite + + // run requests/queries + for _, op := range tx.pending { + store := idbTx.Call("objectStore", op.store) + req := tx.dispatch(store, op) + tx.bind(req, op) + } + + return tx.waitAll() +} + +// A helper to call the JS methods. +func (tx *Transaction) dispatch(store js.Value, op *Operation) js.Value { + switch op.typee { + case opGet: + return store.Call("get", op.key) + case opPut: + return store.Call("put", op.value, op.key) + case opDelete: + return store.Call("delete", op.key) + case opGetAllKeys: + if op.value.IsNull() || op.value.IsUndefined() { // jsArray.Truthy() is always true even if empty + return store.Call("getAllKeys") + } + return store.Call("getAllKeys", op.value) + default: + panic("unknown op") + } +} + +// Binds the callbacks for specified op. +func (tx *Transaction) bind(req js.Value, op *Operation) { + var success, fail js.Func + + success = js.FuncOf(func(this js.Value, args []js.Value) any { + // release memory + defer success.Release() + defer fail.Release() + + op.result = args[0].Get("target").Get("result") + op.done <- 1 + return nil + }) + + fail = js.FuncOf(func(this js.Value, args []js.Value) any { + // release memory + defer success.Release() + defer fail.Release() + + errObj := args[0].Get("target").Get("error") + op.err = fmt.Errorf("IDB error: %s", errObj.String()) + op.done <- 1 + return nil + }) + + req.Set("onsuccess", success) + req.Set("onerror", fail) +} + +// Waits for all op +func (tx *Transaction) waitAll() error { + timeout := time.After(reqTimeout) + + for _, op := range tx.pending { + select { + case <-op.done: + if op.err != nil { + return op.err + } + case <-timeout: + return fmt.Errorf("transaction timeout after %v", reqTimeout) + } + } + return nil +} + +// Result simply returns the stored value, safe to call multiple times. +func (op *Operation) Result() js.Value { + return op.result +}