diff --git a/storage/externalStorage.go b/storage/externalStorage.go new file mode 100644 index 0000000000000000000000000000000000000000..3f41d0bc7f8b764dfe4c712e80aa48a663ffd725 --- /dev/null +++ b/storage/externalStorage.go @@ -0,0 +1,405 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package storage + +import ( + "errors" + "os" + "strings" + "syscall/js" + + "github.com/Max-Sum/base32768" + + "gitlab.com/elixxir/wasm-utils/exception" + "gitlab.com/elixxir/wasm-utils/utils" +) + +// externalStorageWasmPrefix is prefixed to every keyName saved to external storage by +// externalStorage. It allows the identification and deletion of keys only created +// by this WASM binary while ignoring keys made by other scripts on the same +// page. +// +// The chosen prefix is two characters, that when converted to UTF16, take up 4 +// bytes without any zeros to make them more unique. +const externalStorageWasmPrefix = "🞮🞮" + +var UnimplementedErr = errors.New("not implemented") + +// ExternalStorage defines an interface for setting persistent state in a KV format +// specifically for web-based implementations. +type ExternalStorage interface { + // Get decodes and returns the value from the external storage given its key + // name. Returns os.ErrNotExist if the key does not exist. + Get(key string) ([]byte, error) + + // Set encodes the bytes to a string and adds them to external storage at the + // given key name. Returns an error if external storage quota has been reached. + Set(key string, value []byte) error + + // Delete removes a key's value from external storage given its name. If + // there is no item with the given key, this function does nothing. + Delete(keyName string) error + + // Clear clears all the keys in storage. Returns the number of keys cleared and any error. + Clear() (int, error) + + // ClearPrefix clears all keys with the given prefix. Returns the number of + // keys cleared and any error. + ClearPrefix(prefix string) (int, error) + + // Key returns the name of the nth key in externalStorage. Returns + // os.ErrNotExist if the key does not exist. The order of keys is not + // defined. + Key(n int) (string, error) + + // Keys returns a list of all key names in external storage. + Keys() ([]string, error) + + // GetPrefix returns the full Prefix of the KV + GetPrefix() string + + // HasPrefix returns whether this prefix exists in the KV + HasPrefix(prefix string) bool + + // Prefix returns a new KV with the new prefix appending + Prefix(prefix string) (ExternalStorage, error) + + // Root returns the KV with no prefixes + Root() ExternalStorage + + // IsMemStore returns true if the underlying KV is memory based + IsMemStore() (bool, error) + + // Length returns the number of keys in localStorage. + Length() int + + // ExternalStorageUNSAFE returns the underlying external storage wrapper. This can + // be UNSAFE and should only be used if you know what you are doing. + // + // The returned wrapper wraps all the functions and fields on the Javascript + // havenStorage object to handle type conversions and errors. But it does + // not decode/sanitize the inputs/outputs or track entries using the prefix + // system. If using it, make sure all key names and values can be converted + // to valid UCS-2 strings. + ExternalStorageUNSAFE() *HavenStorageJS +} + +// externalStorage contains the js.Value representation of havenStorage. +type externalStorage struct { + // The Javascript value containing the havenStorage object + v *HavenStorageJS + + // The prefix appended to each key name. This is so that all keys created by + // this structure can be deleted without affecting other keys in external + // storage. + prefix string +} + +// jsStorage is the global that stores Javascript as window.havenStorage. +var jsExternalStorage ExternalStorage = newExternalStorage(externalStorageWasmPrefix) + +// checkUnimplementedErr checks if the error is UnimplementedErr, if yes return +// UnimplementedErr otherwise return the error +func checkUnimplementedErr(jsErr []js.Value) error { + // todo it can be of non error type + jsError := js.Error{Value: jsErr[0]} + if jsError.Error() == "JavaScript error: not implemented" { + return UnimplementedErr + } + return jsError +} + +// newExternalStorage creates a new externalStorage object with the specified prefix. +func newExternalStorage(prefix string) *externalStorage { + return &externalStorage{ + v: &HavenStorageJS{js.Global().Get("havenStorage")}, + prefix: prefix, + } +} + +// GetExternalStorage returns Javascript's external storage. +func GetExternalStorage() ExternalStorage { + return jsExternalStorage +} + +// Get decodes and returns the value from the external storage given its key +// name. Returns os.ErrNotExist if the key does not exist. +func (ls *externalStorage) Get(keyName string) ([]byte, error) { + value, err := ls.v.GetItem(ls.prefix + keyName) + if err != nil { + return nil, err + } + + return base32768.SafeEncoding.DecodeString(value) +} + +// Set encodes the bytes to a string and adds them to external storage at the +// given key name. Returns an error if external storage quota has been reached. +func (ls *externalStorage) Set(keyName string, keyValue []byte) error { + encoded := base32768.SafeEncoding.EncodeToString(keyValue) + return ls.v.SetItem(ls.prefix+keyName, encoded) +} + +// RemoveItem removes a key's value from external storage given its name. If there +// is no item with the given key, this function does nothing. +func (ls *externalStorage) Delete(keyName string) error { + return ls.v.Delete(ls.prefix + keyName) +} + +// Clear clears all the keys in storage. Returns the number of keys cleared and any error. +func (ls *externalStorage) Clear() (int, error) { + // Get a copy of all key names at once + keys, err := ls.v.KeysPrefix(ls.prefix) + if err != nil { + return 0, err + } + + // Loop through each key + for _, keyName := range keys { + if err := ls.Delete(keyName); err != nil { + return 0, err + } + } + + return len(keys), nil +} + +// ClearPrefix clears all keys with the given prefix. Returns the number of +// keys cleared and any error. +func (ls *externalStorage) ClearPrefix(prefix string) (int, error) { + // Get a copy of all key names at once + keys, err := ls.v.KeysPrefix(ls.prefix + prefix) + if err != nil { + return 0, err + } + + // Loop through each key + for _, keyName := range keys { + if err := ls.Delete(prefix + keyName); err != nil { + return 0, err + } + } + + return len(keys), nil +} + +// Key returns the name of the nth key in externalStorage. Return [os.ErrNotExist] +// if the key does not exist. The order of keys is not defined. +func (ls *externalStorage) Key(n int) (string, error) { + keyName, err := ls.v.Key(n) + if err != nil { + return "", err + } + return strings.TrimPrefix(keyName, ls.prefix), nil +} + +// Keys returns a list of all key names in external storage. +func (ls *externalStorage) Keys() ([]string, error) { + keys, err := ls.v.KeysPrefix(ls.prefix) + if err != nil { + return nil, err + } + return keys, nil +} + +func (ls *externalStorage) GetPrefix() string { + return ls.prefix +} + +func (ls *externalStorage) HasPrefix(prefix string) bool { + return strings.HasPrefix(ls.prefix, prefix) +} + +func (ls *externalStorage) Prefix(prefix string) (ExternalStorage, error) { + return newExternalStorage(ls.prefix + prefix), nil +} + +func (ls *externalStorage) Root() ExternalStorage { + return newExternalStorage("") +} + +func (ls *externalStorage) IsMemStore() (bool, error) { + return ls.v.IsMemStore() +} + +func (ls *externalStorage) Length() int { + return ls.v.Length() +} + +// ExternalStorageUNSAFE returns the underlying external storage wrapper. This can be +// UNSAFE and should only be used if you know what you are doing. +// +// The returned wrapper wraps all the functions and fields on the Javascript +// havenStorage object to handle type conversions and errors. But it does not +// decode/sanitize the inputs/outputs or track entries using the prefix system. +// If using it, make sure all key names and values can be converted to valid +// UCS-2 strings. +func (ls *externalStorage) ExternalStorageUNSAFE() *HavenStorageJS { + return ls.v +} + +//////////////////////////////////////////////////////////////////////////////// +// Javascript Wrappers // +//////////////////////////////////////////////////////////////////////////////// + +// StorageOperation defines the supported operations for HavenStorageJS +type StorageOperation string + +const ( + // GetItemOp represents the "getItem" operation + GetItemOp StorageOperation = "getItem" + // SetItemOp represents the "setItem" operation + SetItemOp StorageOperation = "setItem" + // DeleteOp represents the "delete" operation + DeleteOp StorageOperation = "delete" + // ClearOp represents the "clear" operation + ClearOp StorageOperation = "clear" + // KeysOp represents the "getKeys" operation + KeysOp StorageOperation = "getKeys" + + // IsMemStoreOp represents the "isMemStore" operation + IsMemStoreOp StorageOperation = "isMemStore" +) + +// HavenStorageJS stores the Javascript window.havenStorage object and wraps all +// of its methods and fields to handle type conversations and errors. +type HavenStorageJS struct { + js.Value +} + +// callStorage is a helper function that calls the specified operation on the storage object +// with the provided arguments and returns the result. +func (ls *HavenStorageJS) callStorage(op StorageOperation, args ...interface{}) js.Value { + return ls.Call(string(op), args...) +} + +// GetItem returns the value from the external storage given its key name. Returns +// [os.ErrNotExist] if the key does not exist. +// +// Doc: https://developer.mozilla.org/en-US/docs/Web/API/Storage/getItem +func (ls *HavenStorageJS) GetItem(keyName string) (keyValue string, err error) { + defer exception.Catch(&err) + promise := ls.callStorage(GetItemOp, keyName) + result, jsErr := utils.Await(promise) + if jsErr != nil { + return "", checkUnimplementedErr(jsErr) + } + if result[0].IsNull() { + return "", os.ErrNotExist + } + return result[0].String(), nil +} + +// SetItem adds the value to external storage at the given key name. Returns an +// error if external storage quota has been reached. +// +// Doc: https://developer.mozilla.org/en-US/docs/Web/API/Storage/setItem +func (ls *HavenStorageJS) SetItem(keyName, keyValue string) (err error) { + defer exception.Catch(&err) + promise := ls.callStorage(SetItemOp, keyName, keyValue) + _, jsErr := utils.Await(promise) + if jsErr != nil { + return checkUnimplementedErr(jsErr) + } + return nil +} + +// RemoveItem removes a key's value from external storage given its name. If there +// is no item with the given key, this function does nothing. +// +// Doc: https://developer.mozilla.org/en-US/docs/Web/API/Storage/removeItem +func (ls *HavenStorageJS) Delete(keyName string) error { + promise := ls.callStorage(DeleteOp, keyName) + _, jsErr := utils.Await(promise) + if jsErr != nil { + return checkUnimplementedErr(jsErr) + } + return nil +} + +// Clear clears all the keys in storage. +// +// Doc: https://developer.mozilla.org/en-US/docs/Web/API/Storage/clear +func (ls *HavenStorageJS) Clear() error { + promise := ls.callStorage(ClearOp) + _, jsErr := utils.Await(promise) + if jsErr != nil { + return checkUnimplementedErr(jsErr) + } + return nil +} + +// Key returns the name of the nth key in externalStorage. Return [os.ErrNotExist] +// if the key does not exist. The order of keys is not defined. +// +// Doc: https://developer.mozilla.org/en-US/docs/Web/API/Storage/key +func (ls *HavenStorageJS) Key(n int) (keyName string, err error) { + defer exception.Catch(&err) + promise := ls.Call("key", n) + result, jsErr := utils.Await(promise) + if jsErr != nil { + return "", checkUnimplementedErr(jsErr) + } + if result[0].IsNull() { + return "", os.ErrNotExist + } + return result[0].String(), nil +} + +// Keys returns a list of all key names in external storage. +func (ls *HavenStorageJS) Keys() ([]string, error) { + promise := ls.callStorage(KeysOp) + result, jsErr := utils.Await(promise) + if jsErr != nil { + return []string{}, checkUnimplementedErr(jsErr) + } + + keysJS := result[0] + keys := make([]string, keysJS.Length()) + for i := range keys { + keys[i] = keysJS.Index(i).String() + } + return keys, nil +} + +// KeysPrefix returns a list of all key names in external storage with the given +// prefix and trims the prefix from each key name. +func (ls *HavenStorageJS) KeysPrefix(prefix string) ([]string, error) { + promise := ls.callStorage(KeysOp) + result, jsErr := utils.Await(promise) + if jsErr != nil { + return []string{}, checkUnimplementedErr(jsErr) + } + + keysJS := result[0] + keys := make([]string, 0, keysJS.Length()) + for i := 0; i < keysJS.Length(); i++ { + keyName := keysJS.Index(i).String() + if strings.HasPrefix(keyName, prefix) { + keys = append(keys, strings.TrimPrefix(keyName, prefix)) + } + } + return keys, nil +} + +func (ls *HavenStorageJS) IsMemStore() (bool, error) { + result := ls.callStorage(IsMemStoreOp) + var err error + defer exception.Catch(&err) + if err != nil { + //TODO + jsErr, ok := err.(js.Error) + if ok { + return false, checkUnimplementedErr([]js.Value{js.ValueOf(jsErr)}) + } + return false, err + } + return result.Bool(), nil +} diff --git a/storage/externalStorage_test.go b/storage/externalStorage_test.go new file mode 100644 index 0000000000000000000000000000000000000000..418d2e0f632ba84fa13bdcd92b81c8a66003d315 --- /dev/null +++ b/storage/externalStorage_test.go @@ -0,0 +1,379 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file. // +//////////////////////////////////////////////////////////////////////////////// + +//go:build js && wasm + +package storage + +import ( + "bytes" + "os" + "reflect" + "strconv" + "syscall/js" + "testing" + + "github.com/pkg/errors" +) + +// setupMockHavenStorage sets up a mock implementation of havenStorage in the JavaScript environment +func setupMockHavenStorage(_ *testing.T) { + const setupScript = ` + const havenStorage = { + getItem: function(key) { + return Promise.resolve(localStorage.getItem(key)); + }, + + setItem: function(key, value) { + localStorage.setItem(key, value); + return Promise.resolve(); + }, + + delete: function(key) { + localStorage.removeItem(key); + return Promise.resolve(); + }, + + clear: function() { + localStorage.clear(); + return Promise.resolve(); + }, + + getKeys: function() { + return Promise.resolve(Object.keys(localStorage)); + }, + + key: function(index) { + // To Test unimplemented error + return Promise.reject(new Error('not implemented')); + }, + + isMemStore: function() { + return Promise.resolve(false); + }, + + length: function() { + return Promise.resolve(0); + }, + }; + + // Initialize mock storage + window._mockHavenStorage = {}; + window.havenStorage = havenStorage; + ` + js.Global().Call("eval", setupScript) + jsExternalStorage = newExternalStorage(externalStorageWasmPrefix) +} + +func TestExternalStorage_GetPrefix(t *testing.T) { + setupMockHavenStorage(t) + + es := GetExternalStorage() + + prefix := es.GetPrefix() + if prefix != externalStorageWasmPrefix { + t.Errorf("Expected prefix to be %q, got %q", externalStorageWasmPrefix, prefix) + } +} + +func TestExternalStorage_HasPrefix(t *testing.T) { + setupMockHavenStorage(t) + + es := GetExternalStorage() + + hasPrefix := es.HasPrefix(externalStorageWasmPrefix) + if !hasPrefix { + t.Errorf("Expected hasPrefix to be true") + } + + hasPrefix = es.HasPrefix("someOtherPrefix") + if hasPrefix { + t.Errorf("Expected hasPrefix to be false") + } +} + +func TestExternalStorage_Prefix(t *testing.T) { + setupMockHavenStorage(t) + + es := GetExternalStorage() + + prefix, err := es.Prefix("testPrefix") + if err != nil { + t.Errorf("Failed to get prefix: %+v", err) + } + + if prefix.GetPrefix() != externalStorageWasmPrefix+"testPrefix" { + t.Errorf("Expected prefix to be %q, got %q", externalStorageWasmPrefix+"testPrefix", prefix.GetPrefix()) + } +} + +func TestExternalStorage_Root(t *testing.T) { + setupMockHavenStorage(t) + + es := GetExternalStorage() + + root := es.Root() + if root.GetPrefix() != "" { + t.Errorf("Expected prefix to be %q, got %q", "", root.GetPrefix()) + } +} + +func TestExternalStorage_IsMemStore(t *testing.T) { + setupMockHavenStorage(t) + + es := GetExternalStorage() + + isMemStore, err := es.IsMemStore() + if err != nil { + t.Errorf("Failed to get isMemStore: %+v", err) + } + + if isMemStore { + t.Errorf("Expected memstore to be false") + } +} + +func TestExternalStorage_Length(t *testing.T) { + setupMockHavenStorage(t) + + es := GetExternalStorage() + + length := es.Length() + if length != 0 { + t.Errorf("Expected length to be %d, got %d", 0, length) + } +} + +// Unit test of GetExternalStorage. +func TestGetExternalStorage(t *testing.T) { + setupMockHavenStorage(t) + + expected := &externalStorage{ + v: &HavenStorageJS{js.Global().Get("havenStorage")}, + prefix: externalStorageWasmPrefix, + } + + es := GetExternalStorage() + + if !reflect.DeepEqual(expected, es) { + t.Errorf("Did not receive expected externalStorage."+ + "\nexpected: %+v\nreceived: %+v", expected, es) + } +} + +// Tests that a value set with externalStorage.Set and retrieved with +// externalStorage.Get matches the original. +func TestExternalStorage_Get_Set(t *testing.T) { + setupMockHavenStorage(t) + + values := map[string][]byte{ + "key1": []byte("key value"), + "key2": {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, + "key3": {0, 49, 0, 0, 0, 38, 249, 93, 242, 189, 222, 32, 138, 248, 121, + 151, 42, 108, 82, 199, 163, 61, 4, 200, 140, 231, 225, 20, 35, 243, + 253, 161, 61, 2, 227, 208, 173, 183, 33, 66, 236, 107, 105, 119, 26, + 42, 44, 60, 109, 172, 38, 47, 220, 17, 129, 4, 234, 241, 141, 81, + 84, 185, 32, 120, 115, 151, 128, 196, 143, 117, 222, 78, 44, 115, + 109, 20, 249, 46, 158, 139, 231, 157, 54, 219, 141, 252}, + } + + for keyName, keyValue := range values { + err := jsExternalStorage.Set(keyName, keyValue) + if err != nil { + t.Errorf("Failed to set %q: %+v", keyName, err) + } + + loadedValue, err := jsExternalStorage.Get(keyName) + if err != nil { + t.Errorf("Failed to load %q: %+v", keyName, err) + } else if !bytes.Equal(keyValue, loadedValue) { + t.Errorf("Loaded value does not match original for %q"+ + "\nexpected: %q\nreceived: %q", keyName, keyValue, loadedValue) + } + } +} + +// Tests that externalStorage.Delete deletes a key from the store and that it +// cannot be retrieved. +func TestExternalStorage_Delete(t *testing.T) { + setupMockHavenStorage(t) + + keyName := "key" + if err := jsExternalStorage.Set(keyName, []byte("value")); err != nil { + t.Errorf("Failed to set %q: %+v", keyName, err) + } + + err := jsExternalStorage.Delete(keyName) + if err != nil { + t.Errorf("Failed to delete key %q: %+v", keyName, err) + } + + _, err = jsExternalStorage.Get(keyName) + if err == nil || !errors.Is(err, os.ErrNotExist) { + t.Errorf("Failed to remove %q: %+v", keyName, err) + } +} + +// Tests that externalStorage.Clear deletes all the WASM keys from storage and +// does not remove any others +func TestExternalStorage_Clear(t *testing.T) { + setupMockHavenStorage(t) + + // Clear any existing data + jsExternalStorage.ExternalStorageUNSAFE().Clear() + + const numKeys = 10 + var yesPrefix, noPrefix []string + + for i := 0; i < numKeys; i++ { + keyName := "keyNum" + strconv.Itoa(i) + if i%2 == 0 { + yesPrefix = append(yesPrefix, keyName) + err := jsExternalStorage.Set(keyName, []byte(strconv.Itoa(i))) + if err != nil { + t.Errorf("Failed to set with prefix %q: %+v", keyName, err) + } + } else { + noPrefix = append(noPrefix, keyName) + err := jsExternalStorage.ExternalStorageUNSAFE().SetItem(keyName, strconv.Itoa(i)) + if err != nil { + t.Errorf("Failed to set with no prefix %q: %+v", keyName, err) + } + } + } + + n, err := jsExternalStorage.Clear() + if err != nil { + t.Errorf("Failed to clear storage: %+v", err) + } + if n != numKeys/2 { + t.Errorf("Incorrect number of keys.\nexpected: %d\nreceived: %d", + numKeys/2, n) + } + + for _, keyName := range noPrefix { + if _, err := jsExternalStorage.ExternalStorageUNSAFE().GetItem(keyName); err != nil { + t.Errorf("Could not get keyName %q: %+v", keyName, err) + } + } + for _, keyName := range yesPrefix { + keyValue, err := jsExternalStorage.Get(keyName) + if err == nil || !errors.Is(err, os.ErrNotExist) { + t.Errorf("Found keyName %q: %q", keyName, keyValue) + } + } +} + +// Tests that externalStorage.ClearPrefix deletes only the keys with the given +// prefix. +func TestExternalStorage_ClearPrefix(t *testing.T) { + setupMockHavenStorage(t) + + // Clear any existing data + jsExternalStorage.ExternalStorageUNSAFE().Clear() + + const numKeys = 10 + var yesPrefix, noPrefix []string + prefix := "keyNamePrefix/" + + for i := 0; i < numKeys; i++ { + keyName := "keyNum " + strconv.Itoa(i) + if i%2 == 0 { + keyName = prefix + keyName + yesPrefix = append(yesPrefix, keyName) + } else { + noPrefix = append(noPrefix, keyName) + } + + if err := jsExternalStorage.Set(keyName, []byte(strconv.Itoa(i))); err != nil { + t.Errorf("Failed to set %q: %+v", keyName, err) + } + } + + n, err := jsExternalStorage.ClearPrefix(prefix) + if err != nil { + t.Errorf("Failed to clear prefix: %+v", err) + } + if n != numKeys/2 { + t.Errorf("Incorrect number of keys.\nexpected: %d\nreceived: %d", + numKeys/2, n) + } + + for _, keyName := range noPrefix { + if _, err := jsExternalStorage.Get(keyName); err != nil { + t.Errorf("Could not get keyName %q: %+v", keyName, err) + } + } + for _, keyName := range yesPrefix { + keyValue, err := jsExternalStorage.Get(keyName) + if err == nil || !errors.Is(err, os.ErrNotExist) { + t.Errorf("Found keyName %q: %q", keyName, keyValue) + } + } +} + +// Tests that externalStorage.Key return all added keys when looping through all +// indexes. +func TestExternalStorage_Key(t *testing.T) { + setupMockHavenStorage(t) + + // This test will verify that the UnimplementedErr is properly returned + // since the mock implementation rejects the key method + _, err := jsExternalStorage.Key(0) + if err == nil || !errors.Is(err, UnimplementedErr) { + t.Errorf("Expected UnimplementedErr for Key method.\nexpected: %v\nreceived: %v", + UnimplementedErr, err) + } +} + +// Tests that externalStorage.Get returns the error os.ErrNotExist when the key +// does not exist in storage. +func TestExternalStorage_Get_NotExistError(t *testing.T) { + setupMockHavenStorage(t) + + _, err := jsExternalStorage.Get("someKey") + if err == nil || !errors.Is(err, os.ErrNotExist) { + t.Errorf("Incorrect error for non existant key."+ + "\nexpected: %v\nreceived: %v", os.ErrNotExist, err) + } +} + +// Tests that externalStorage.Keys return a list that contains all the added keys. +func TestExternalStorage_Keys(t *testing.T) { + setupMockHavenStorage(t) + + // Clear any existing data + jsExternalStorage.ExternalStorageUNSAFE().Clear() + + values := map[string][]byte{ + "key1": []byte("key value"), + "key2": {0, 1, 2, 3, 4, 5, 6, 7, 8, 9}, + "key3": {0, 49, 0, 0, 0, 38, 249, 93}, + } + + for keyName, keyValue := range values { + if err := jsExternalStorage.Set(keyName, keyValue); err != nil { + t.Errorf("Failed to set %q: %+v", keyName, err) + } + } + + keys, err := jsExternalStorage.Keys() + if err != nil { + t.Errorf("Failed to get keys: %+v", err) + } + + if len(keys) != len(values) { + t.Errorf("Incorrect number of keys.\nexpected: %d\nreceived: %d", + len(values), len(keys)) + } + + for i, keyName := range keys { + if _, exists := values[keyName]; !exists { + t.Errorf("Key %q does not exist (%d).", keyName, i) + } + } +}