diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..8551215fa5b651bb93a247cde5ab89d4b8f5f7e6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) 2022, xx network SEZC + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this +list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, +this list of conditions and the following disclaimer in the documentation and/or +other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/go.mod b/go.mod index 3d81f193620e6bf8bcd40aea90b8673339104167..b9605aa9bf8b506f1b1906a9a9be0f5e6083446f 100644 --- a/go.mod +++ b/go.mod @@ -3,10 +3,13 @@ module gitlab.com/elixxir/xxdk-wasm go 1.17 require ( - github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 + github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e + github.com/hack-pad/go-indexeddb v0.2.0 github.com/pkg/errors v0.9.1 github.com/spf13/jwalterweatherman v1.1.0 gitlab.com/elixxir/client v1.5.1-0.20220914170015-49119bef386e + gitlab.com/elixxir/crypto v0.0.7-0.20220913220142-ab0771bad0af + gitlab.com/xx_network/primitives v0.0.4-0.20220809193445-9fc0a5209548 ) require ( @@ -36,12 +39,10 @@ require ( github.com/zeebo/blake3 v0.2.3 // indirect gitlab.com/elixxir/bloomfilter v0.0.0-20211222005329-7d931ceead6f // indirect gitlab.com/elixxir/comms v0.0.4-0.20220913220502-eed192f654bd // indirect - gitlab.com/elixxir/crypto v0.0.7-0.20220913220142-ab0771bad0af // indirect gitlab.com/elixxir/ekv v0.2.1 // indirect gitlab.com/elixxir/primitives v0.0.3-0.20220901220638-1acc75fabdc6 // indirect gitlab.com/xx_network/comms v0.0.4-0.20220913215811-c4bf83b27de3 // indirect gitlab.com/xx_network/crypto v0.0.5-0.20220913213008-98764f5b3287 // indirect - gitlab.com/xx_network/primitives v0.0.4-0.20220809193445-9fc0a5209548 // indirect gitlab.com/xx_network/ring v0.0.3-0.20220222211904-da613960ad93 // indirect go.uber.org/atomic v1.10.0 // indirect go.uber.org/ratelimit v0.2.0 // indirect diff --git a/go.sum b/go.sum index c5d7a5ef774823b4fe61244fbac30ed833032d08..149c6f8950f60d2b0f214c89460173b5bf01ff1a 100644 --- a/go.sum +++ b/go.sum @@ -75,9 +75,8 @@ github.com/andres-erbsen/clock v0.0.0-20160526145045-9e14626cd129/go.mod h1:rFgp github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/apache/thrift v0.12.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= github.com/apache/thrift v0.13.0/go.mod h1:cp2SuWMxlEZw2r+iP2GNCdIi4C1qmUzdZFSVb+bacwQ= +github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e h1:QEF07wC0T1rKkctt1RINW/+RMTVmiwxETico2l3gxJA= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= -github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2 h1:7Ip0wMmLHLRJdrloDxZfhMm0xrLXZS8+COSu2bXmEQs= -github.com/armon/circbuf v0.0.0-20190214190532-5111143e8da2/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o= github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= @@ -301,6 +300,8 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.2.2/go.mod h1:EaizFBKfUKtMIF5iaD github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw= +github.com/hack-pad/go-indexeddb v0.2.0 h1:QHDM6gLrtCJvHdHUK8UdibJu4xWQlIDs4+l8L65AUdA= +github.com/hack-pad/go-indexeddb v0.2.0/go.mod h1:NH8CaojufPNcKYDhy5JkjfyBXE/72oJPeiywlabN/lM= github.com/hashicorp/consul/api v1.3.0/go.mod h1:MmDNSzIMUjNpY/mQ398R4bk2FnqQLoPndWW5VkKPlCE= github.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0= github.com/hashicorp/consul/sdk v0.3.0/go.mod h1:VKf9jXwCTEY1QZP2MOLRhb5i/I/ssyNV1vwHyQBF0x8= diff --git a/indexedDb/implementation.go b/indexedDb/implementation.go new file mode 100644 index 0000000000000000000000000000000000000000..632bd1317f09a9ebfb17163ae8f690f9c9986350 --- /dev/null +++ b/indexedDb/implementation.go @@ -0,0 +1,360 @@ +//////////////////////////////////////////////////////////////////////////////// +// 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 +// +build js,wasm + +package indexedDb + +import ( + "context" + "encoding/base64" + "encoding/json" + "github.com/hack-pad/go-indexeddb/idb" + "github.com/pkg/errors" + jww "github.com/spf13/jwalterweatherman" + "gitlab.com/elixxir/xxdk-wasm/utils" + "syscall/js" + "time" + + "gitlab.com/elixxir/client/channels" + "gitlab.com/elixxir/client/cmix/rounds" + cryptoBroadcast "gitlab.com/elixxir/crypto/broadcast" + cryptoChannel "gitlab.com/elixxir/crypto/channel" + "gitlab.com/xx_network/primitives/id" +) + +// dbTimeout is the global timeout for operations with the storage context.Contact +const dbTimeout = time.Second + +// wasmModel implements [channels.EventModel] interface which uses the channels +// system passed an object which adheres to in order to get events on the channel. +type wasmModel struct { + db *idb.Database +} + +// newContext builds a context for database operations +func newContext() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), dbTimeout) +} + +// JoinChannel is called whenever a channel is joined locally. +func (w *wasmModel) JoinChannel(channel *cryptoBroadcast.Channel) { + parentErr := errors.New("failed to JoinChannel") + + // Build object + newChannel := Channel{ + Id: channel.ReceptionID.Marshal(), + Name: channel.Name, + Description: channel.Description, + } + + // Convert to jsObject + newChannelJson, err := json.Marshal(&newChannel) + if err != nil { + jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, + "Unable to marshal Channel: %+v", err)) + return + } + channelObj, err := utils.JsonToJS(newChannelJson) + if err != nil { + jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, + "Unable to marshal Channel: %+v", err)) + return + } + + // Prepare the Transaction + txn, err := w.db.Transaction(idb.TransactionReadWrite, channelsStoreName) + if err != nil { + jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, + "Unable to create Transaction: %+v", err)) + return + } + store, err := txn.ObjectStore(channelsStoreName) + if err != nil { + jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, + "Unable to get ObjectStore: %+v", err)) + return + } + + // Perform the operation + _, err = store.Add(channelObj) + if err != nil { + jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, + "Unable to Add Channel: %+v", err)) + return + } + + // Wait for the operation to return + ctx, cancel := newContext() + err = txn.Await(ctx) + cancel() + if err != nil { + jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, + "Adding Channel failed: %+v", err)) + return + } + jww.DEBUG.Printf("Successfully added channel: %s", + channel.ReceptionID.String()) +} + +// LeaveChannel is called whenever a channel is left locally. +func (w *wasmModel) LeaveChannel(channelID *id.ID) { + parentErr := errors.New("failed to LeaveChannel") + + // Prepare the Transaction + txn, err := w.db.Transaction(idb.TransactionReadWrite, channelsStoreName) + if err != nil { + jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, + "Unable to create Transaction: %+v", err)) + return + } + store, err := txn.ObjectStore(channelsStoreName) + if err != nil { + jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, + "Unable to get ObjectStore: %+v", err)) + return + } + + // Perform the operation + _, err = store.Delete(js.ValueOf(channelID.String())) + if err != nil { + jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, + "Unable to Delete Channel: %+v", err)) + return + } + + // Wait for the operation to return + ctx, cancel := newContext() + err = txn.Await(ctx) + cancel() + if err != nil { + jww.ERROR.Printf("%+v", errors.WithMessagef(parentErr, + "Deleting Channel failed: %+v", err)) + return + } + jww.DEBUG.Printf("Successfully deleted channel: %s", channelID.String()) +} + +// ReceiveMessage is called whenever a message is received on a given channel +// It may be called multiple times on the same message, it is incumbent on +// the user of the API to filter such called by message ID. +func (w *wasmModel) ReceiveMessage(channelID *id.ID, messageID cryptoChannel.MessageID, + senderUsername string, text string, timestamp time.Time, lease time.Duration, + _ rounds.Round, status channels.SentStatus) { + parentErr := errors.New("failed to ReceiveMessage") + + err := w.receiveHelper(buildMessage(channelID.Marshal(), messageID.Bytes(), + nil, senderUsername, text, timestamp, lease, status)) + if err != nil { + jww.ERROR.Printf("%+v", errors.Wrap(parentErr, err.Error())) + } +} + +// ReceiveReply is called whenever a message is received which is a reply +// on a given channel. It may be called multiple times on the same message, +// it is incumbent on the user of the API to filter such called by message ID +// Messages may arrive our of order, so a reply in theory can arrive before +// the initial message, as a result it may be important to buffer replies. +func (w *wasmModel) ReceiveReply(channelID *id.ID, messageID cryptoChannel.MessageID, + replyTo cryptoChannel.MessageID, senderUsername string, text string, + timestamp time.Time, lease time.Duration, _ rounds.Round, status channels.SentStatus) { + parentErr := errors.New("failed to ReceiveReply") + + err := w.receiveHelper(buildMessage(channelID.Marshal(), messageID.Bytes(), + replyTo.Bytes(), senderUsername, text, timestamp, lease, status)) + if err != nil { + jww.ERROR.Printf("%+v", errors.Wrap(parentErr, err.Error())) + } +} + +// ReceiveReaction is called whenever a reaction to a message is received +// on a given channel. It may be called multiple times on the same reaction, +// it is incumbent on the user of the API to filter such called by message ID +// Messages may arrive our of order, so a reply in theory can arrive before +// the initial message, as a result it may be important to buffer reactions. +func (w *wasmModel) ReceiveReaction(channelID *id.ID, messageID cryptoChannel.MessageID, + reactionTo cryptoChannel.MessageID, senderUsername string, reaction string, + timestamp time.Time, lease time.Duration, _ rounds.Round, status channels.SentStatus) { + parentErr := errors.New("failed to ReceiveReaction") + + err := w.receiveHelper(buildMessage(channelID.Marshal(), messageID.Bytes(), + reactionTo.Bytes(), senderUsername, reaction, timestamp, lease, status)) + if err != nil { + jww.ERROR.Printf("%+v", errors.Wrap(parentErr, err.Error())) + } +} + +// UpdateSentStatus is called whenever the SentStatus of a message +// has changed +// TODO: Potential race condition due to separate get/update operations +func (w *wasmModel) UpdateSentStatus(messageID cryptoChannel.MessageID, + status channels.SentStatus) { + parentErr := errors.New("failed to UpdateSentStatus") + + // Convert messageID to the key generated by json.Marshal + key := js.ValueOf(base64.StdEncoding.EncodeToString(messageID[:])) + + // Use the key to get the existing Message + currentMsg, err := w.get(messageStoreName, key) + if err != nil { + return + } + + // Extract the existing Message and update the Status + newMessage := &Message{} + err = json.Unmarshal([]byte(currentMsg), newMessage) + if err != nil { + return + } + newMessage.Status = uint8(status) + + // Store the updated Message + err = w.receiveHelper(newMessage) + if err != nil { + jww.ERROR.Printf("%+v", errors.Wrap(parentErr, err.Error())) + } +} + +// buildMessage is a private helper that converts typical [channels.EventModel] +// inputs into a basic Message structure for insertion into storage +func buildMessage(channelID []byte, messageID []byte, + parentId []byte, senderUsername string, text string, + timestamp time.Time, lease time.Duration, status channels.SentStatus) *Message { + return &Message{ + Id: messageID, + SenderUsername: senderUsername, + ChannelId: channelID, + ParentMessageId: parentId, + Timestamp: timestamp, + Lease: lease, + Status: uint8(status), + Hidden: false, + Pinned: false, + Text: text, + } +} + +// receiveHelper is a private helper for receiving any sort of message +func (w *wasmModel) receiveHelper(newMessage *Message) error { + // Convert to jsObject + newMessageJson, err := json.Marshal(newMessage) + if err != nil { + return errors.Errorf("Unable to marshal Message: %+v", err) + } + messageObj, err := utils.JsonToJS(newMessageJson) + if err != nil { + return errors.Errorf("Unable to marshal Message: %+v", err) + } + + // Prepare the Transaction + txn, err := w.db.Transaction(idb.TransactionReadWrite, messageStoreName) + if err != nil { + return errors.Errorf("Unable to create Transaction: %+v", err) + } + store, err := txn.ObjectStore(messageStoreName) + if err != nil { + return errors.Errorf("Unable to get ObjectStore: %+v", err) + } + + // Perform the upsert (put) operation + _, err = store.Put(messageObj) + if err != nil { + return errors.Errorf("Unable to upsert Message: %+v", err) + } + + // Wait for the operation to return + ctx, cancel := newContext() + err = txn.Await(ctx) + cancel() + if err != nil { + return errors.Errorf("Upserting Message failed: %+v", err) + } + jww.DEBUG.Printf("Successfully stored message from %s", + newMessage.SenderUsername) + return nil +} + +// get is a generic private helper for getting values from the given [idb.ObjectStore]. +func (w *wasmModel) get(objectStoreName string, key js.Value) (string, error) { + parentErr := errors.Errorf("failed to get %s/%s", objectStoreName, key) + + // Prepare the Transaction + txn, err := w.db.Transaction(idb.TransactionReadOnly, objectStoreName) + if err != nil { + return "", errors.WithMessagef(parentErr, + "Unable to create Transaction: %+v", err) + } + store, err := txn.ObjectStore(objectStoreName) + if err != nil { + return "", errors.WithMessagef(parentErr, + "Unable to get ObjectStore: %+v", err) + } + + // Perform the operation + getRequest, err := store.Get(key) + if err != nil { + return "", errors.WithMessagef(parentErr, + "Unable to Get from ObjectStore: %+v", err) + } + + // Wait for the operation to return + ctx, cancel := newContext() + resultObj, err := getRequest.Await(ctx) + cancel() + if err != nil { + return "", errors.WithMessagef(parentErr, + "Unable to get from ObjectStore: %+v", err) + } + + // Process result into string + resultStr := utils.JsToJson(resultObj) + jww.DEBUG.Printf("Got from %s/%s: %s", objectStoreName, key, resultStr) + return resultStr, nil +} + +// dump given [idb.ObjectStore] contents to string slice for debugging purposes +func (w *wasmModel) dump(objectStoreName string) ([]string, error) { + parentErr := errors.Errorf("failed to dump %s", objectStoreName) + + txn, err := w.db.Transaction(idb.TransactionReadOnly, objectStoreName) + if err != nil { + return nil, errors.WithMessagef(parentErr, + "Unable to create Transaction: %+v", err) + } + store, err := txn.ObjectStore(objectStoreName) + if err != nil { + return nil, errors.WithMessagef(parentErr, + "Unable to get ObjectStore: %+v", err) + } + cursorRequest, err := store.OpenCursor(idb.CursorNext) + if err != nil { + return nil, errors.WithMessagef(parentErr, + "Unable to open Cursor: %+v", err) + } + + // Run the query + jww.DEBUG.Printf("%s values:", objectStoreName) + results := make([]string, 0) + ctx, cancel := newContext() + err = cursorRequest.Iter(ctx, func(cursor *idb.CursorWithValue) error { + value, err := cursor.Value() + if err != nil { + return err + } + valueStr := utils.JsToJson(value) + results = append(results, valueStr) + jww.DEBUG.Printf("- %v", valueStr) + return nil + }) + cancel() + if err != nil { + return nil, errors.WithMessagef(parentErr, + "Unable to dump ObjectStore: %+v", err) + } + return results, nil +} diff --git a/indexedDb/implementation_test.go b/indexedDb/implementation_test.go new file mode 100644 index 0000000000000000000000000000000000000000..a7b005abb782c0151bb0045d0eb9239c611ac947 --- /dev/null +++ b/indexedDb/implementation_test.go @@ -0,0 +1,118 @@ +//////////////////////////////////////////////////////////////////////////////// +// Copyright © 2022 xx foundation // +// // +// Use of this source code is governed by a license that can be found in the // +// LICENSE file // +//////////////////////////////////////////////////////////////////////////////// + +package indexedDb + +import ( + "encoding/json" + jww "github.com/spf13/jwalterweatherman" + "gitlab.com/elixxir/client/channels" + cryptoBroadcast "gitlab.com/elixxir/crypto/broadcast" + "gitlab.com/elixxir/crypto/channel" + "gitlab.com/xx_network/primitives/id" + "os" + "testing" + "time" +) + +func TestMain(m *testing.M) { + jww.SetStdoutThreshold(jww.LevelDebug) + os.Exit(m.Run()) +} + +// Test UpdateSentStatus happy path and ensure fields don't change +func TestWasmModel_UpdateSentStatus(t *testing.T) { + testString := "test" + testMsgId := channel.MakeMessageID([]byte(testString)) + eventModel, err := newWasmModel(testString) + if err != nil { + t.Fatalf("%+v", err) + } + + // Store a test message + testMsg := buildMessage([]byte(testString), testMsgId.Bytes(), + nil, testString, testString, time.Now(), time.Second, channels.Sent) + err = eventModel.receiveHelper(testMsg) + if err != nil { + t.Fatalf("%+v", err) + } + + // Ensure one message is stored + results, err := eventModel.dump(messageStoreName) + if err != nil { + t.Fatalf("%+v", err) + } + if len(results) != 1 { + t.Fatalf("Expected 1 message to exist") + } + + // Update the sentStatus + expectedStatus := channels.Failed + eventModel.UpdateSentStatus(testMsgId, expectedStatus) + + // Check the resulting status + results, err = eventModel.dump(messageStoreName) + if err != nil { + t.Fatalf("%+v", err) + } + if len(results) != 1 { + t.Fatalf("Expected 1 message to exist") + } + resultMsg := &Message{} + err = json.Unmarshal([]byte(results[0]), resultMsg) + if err != nil { + t.Fatalf("%+v", err) + } + if resultMsg.Status != uint8(expectedStatus) { + t.Fatalf("Unexpected Status: %v", resultMsg.Status) + } + + // Make sure other fields didn't change + if resultMsg.SenderUsername != testString { + t.Fatalf("Unexpected SenderUsername: %v", resultMsg.SenderUsername) + } +} + +// Smoke test JoinChannel/LeaveChannel happy paths +func TestWasmModel_JoinChannel_LeaveChannel(t *testing.T) { + eventModel, err := newWasmModel("test") + if err != nil { + t.Fatalf("%+v", err) + } + + testChannel := &cryptoBroadcast.Channel{ + ReceptionID: id.NewIdFromString("test", id.Generic, t), + Name: "test", + Description: "test", + Salt: nil, + RsaPubKey: nil, + } + testChannel2 := &cryptoBroadcast.Channel{ + ReceptionID: id.NewIdFromString("test2", id.Generic, t), + Name: "test2", + Description: "test2", + Salt: nil, + RsaPubKey: nil, + } + eventModel.JoinChannel(testChannel) + eventModel.JoinChannel(testChannel2) + results, err := eventModel.dump(channelsStoreName) + if err != nil { + t.Fatalf("%+v", err) + } + if len(results) != 2 { + t.Fatalf("Expected 2 channels to exist") + } + eventModel.LeaveChannel(testChannel.ReceptionID) + results, err = eventModel.dump(channelsStoreName) + if err != nil { + t.Fatalf("%+v", err) + } + if len(results) != 1 { + t.Fatalf("Expected 1 channels to exist") + } +} diff --git a/indexedDb/init.go b/indexedDb/init.go new file mode 100644 index 0000000000000000000000000000000000000000..f256b935111a330c30779eb697a1f6da4195dc84 --- /dev/null +++ b/indexedDb/init.go @@ -0,0 +1,110 @@ +//////////////////////////////////////////////////////////////////////////////// +// 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 +// +build js,wasm + +package indexedDb + +import ( + "github.com/hack-pad/go-indexeddb/idb" + "github.com/pkg/errors" + jww "github.com/spf13/jwalterweatherman" + "syscall/js" + + "gitlab.com/elixxir/client/channels" +) + +const ( + // databaseSuffix to be appended to the name of the database + databaseSuffix = "_messenger" + // currentVersion of the IndexDb runtime. Used for migration purposes. + currentVersion uint = 1 +) + +// NewWasmEventModel returns a [channels.EventModel] backed by a wasmModel +func NewWasmEventModel(username string) (channels.EventModel, error) { + databaseName := username + databaseSuffix + return newWasmModel(databaseName) +} + +// newWasmModel creates the given [idb.Database] and returns a wasmModel +func newWasmModel(databaseName string) (*wasmModel, error) { + // Attempt to open database object + ctx, cancel := newContext() + defer cancel() + openRequest, _ := idb.Global().Open(ctx, databaseName, currentVersion, + func(db *idb.Database, oldVersion, newVersion uint) error { + if oldVersion == newVersion { + jww.INFO.Printf("IndexDb version is current: v%d", + newVersion) + return nil + } + + jww.INFO.Printf("IndexDb upgrade required: v%d -> v%d", + oldVersion, newVersion) + + if oldVersion == 0 && newVersion == 1 { + return v1Upgrade(db) + } + + return errors.Errorf("Invalid version upgrade path: v%d -> v%d", + oldVersion, newVersion) + }) + + // Wait for database open to finish + db, err := openRequest.Await(ctx) + + return &wasmModel{db: db}, err +} + +// v1Upgrade performs the v0 -> v1 database upgrade. +// This can never be changed without permanently breaking backwards compatibility. +func v1Upgrade(db *idb.Database) error { + storeOpts := idb.ObjectStoreOptions{ + KeyPath: js.ValueOf(pkeyName), + AutoIncrement: false, + } + indexOpts := idb.IndexOptions{ + Unique: false, + MultiEntry: false, + } + + // Build Message ObjectStore and Indexes + messageStore, err := db.CreateObjectStore(messageStoreName, storeOpts) + if err != nil { + return err + } + _, err = messageStore.CreateIndex(messageStoreChannelIndex, + js.ValueOf(messageStoreChannel), indexOpts) + if err != nil { + return err + } + _, err = messageStore.CreateIndex(messageStoreParentIndex, + js.ValueOf(messageStoreParent), indexOpts) + if err != nil { + return err + } + _, err = messageStore.CreateIndex(messageStoreTimestampIndex, + js.ValueOf(messageStoreTimestamp), indexOpts) + if err != nil { + return err + } + _, err = messageStore.CreateIndex(messageStorePinnedIndex, + js.ValueOf(messageStorePinned), indexOpts) + if err != nil { + return err + } + + // Build Channel ObjectStore + _, err = db.CreateObjectStore(channelsStoreName, storeOpts) + if err != nil { + return err + } + + return nil +} diff --git a/indexedDb/model.go b/indexedDb/model.go new file mode 100644 index 0000000000000000000000000000000000000000..9354e67a869c87f70824dc52fecc67a1058671d2 --- /dev/null +++ b/indexedDb/model.go @@ -0,0 +1,60 @@ +//////////////////////////////////////////////////////////////////////////////// +// 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 +// +build js,wasm + +package indexedDb + +import ( + "time" +) + +const ( + // Text representation of primary key value (keyPath). + pkeyName = "id" + + // Text representation of the names of the various [idb.ObjectStore]. + messageStoreName = "messages" + channelsStoreName = "channels" + + // Message index names. + messageStoreChannelIndex = "channel_id_index" + messageStoreParentIndex = "parent_message_id_index" + messageStoreTimestampIndex = "timestamp_index" + messageStorePinnedIndex = "pinned_index" + + // Message keyPath names (must match json struct tags). + messageStoreChannel = "channel_id" + messageStoreParent = "parent_message_id" + messageStoreTimestamp = "timestamp" + messageStorePinned = "pinned" +) + +// Message defines the IndexedDb representation of a single Message. +// A Message belongs to one Channel. +// A Message may belong to one Message (Parent). +type Message struct { + Id []byte `json:"id"` // Matches pkeyName + SenderUsername string `json:"sender_username"` + ChannelId []byte `json:"channel_id"` // Index + ParentMessageId []byte `json:"parent_message_id"` // Index + Timestamp time.Time `json:"timestamp"` // Index + Lease time.Duration `json:"lease"` + Status uint8 `json:"status"` + Hidden bool `json:"hidden"` + Pinned bool `json:"pinned"` // Index + Text string `json:"text"` +} + +// Channel defines the IndexedDb representation of a single Channel +// A Channel has many Message. +type Channel struct { + Id []byte `json:"id"` // Matches pkeyName + Name string `json:"name"` + Description string `json:"description"` +} diff --git a/utils/convert.go b/utils/convert.go index 91a679f97a1919da8c1c0e65a4e4ce8950d334df..589979abb11553523cbde7df89b0d9691f03721c 100644 --- a/utils/convert.go +++ b/utils/convert.go @@ -33,10 +33,10 @@ func JsToJson(value js.Value) string { return JSON.Call("stringify", value).String() } -// JsonToJS converts a JSON bytes to a [js.Value] of the object subtype. -func JsonToJS(src []byte) (js.Value, error) { +// JsonToJS converts a JSON bytes input to a [js.Value] of the object subtype. +func JsonToJS(inputJson []byte) (js.Value, error) { var jsObj map[string]interface{} - err := json.Unmarshal(src, &jsObj) + err := json.Unmarshal(inputJson, &jsObj) if err != nil { return js.ValueOf(nil), err }