datastores/signaling/SignalingDataStore.js

/**
 *  SignalingDataStore - signals data over webstrate signals
 * 
 *  This code is licensed under the MIT License (MIT).
 *  
 *  Copyright 2024, Janus B. Kristensen, CAVI,
 *  Center for Advanced Visualization and Interaction, Aarhus University
 *  
 *  Permission is hereby granted, free of charge, to any person obtaining a copy
 *  of this software and associated documentation files (the “Software”), to deal
 *  in the Software without restriction, including without limitation the rights 
 *  to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 
 *  copies of the Software, and to permit persons to whom the Software is 
 *  furnished to do so, subject to the following conditions:
 *  
 *  The above copyright notice and this permission notice shall be included in 
 *  all copies or substantial portions of the Software.
 *  
 *  THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 
 *  IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 
 *  FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 
 *  AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 
 *  LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 
 *  OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 
 *  THE SOFTWARE.
 *  
 */

/**
 * A general purpose datastore that signals changes to data over webstrate signals
 * 
 * This datastore registers as the type "signaling".
 *
 * ### Options
 * * "storageName" - The element name of the DOM element to send signals through (default: "varv-signaling")
 *
 * @memberOf Datastores
 * @example
 * {
 *   "dataStores": { 
 *      "myDataStore": {
 *          "type": "signaling", 
 *          "options": {
 *              "storageName": "my-signals"
 *          }
 *      },
 *      ...
 *  },
 *  ...
 *
 */
class SignalingDataStore extends DirectDatastore {    
    constructor(name, options = {}) {
        super(name, options);

        this.deleteCallbacks = [];
        this.inflightChanges = new Set();
    }

    isShared() {
        return true;
    }

    destroy() {
        this.deleteCallbacks.forEach((deleteCallback)=>{
            deleteCallback.delete();
        });
    }

    async init() {
        const self = this;
        
        let storageName = "varv-signaling";
        if(this.options.storageName != null) {
            storageName = this.options.storageName;
        }
        
        // Try to find an element to signal on
        let topElement = document;
        this.backingElement = topElement.querySelector(storageName);
        if (!this.backingElement) {
            this.backingElement = topElement.createElement(storageName, {approved: true});
            topElement.body.appendChild(this.backingElement);
        }                
        let signalFunction = (message,sender)=>self.receiveSignal(message,sender)
        this.backingElement.webstrate.on("signal", signalFunction);
        this.deleteCallbacks.push({
            delete: ()=>{
                this.backingElement.webstrate.off("signal", signalFunction);
            }
        });


        this.deleteCallbacks.push(VarvEngine.registerEventCallback("disappeared", async (context)=> {
            if(SignalingDataStore.DEBUG) {
                console.log("Saw disappeared UUID (SignalingDataStore):", context.target);
            }

            context.concept.properties.forEach((property) => {
                if (self.isPropertyMapped(context.concept, property)) {
                    let data = self.internalPropertyTrackingData(context.concept, property);
                    delete data[context.target];
                }
            });
            
            // Broadcast to everyone
            this.backingElement.webstrate.signal({
                q: "notifyDisappeared",
                type: context.concept.name,
                uuid: context.target
            });
        }));
        this.deleteCallbacks.push(VarvEngine.registerEventCallback("appeared", async (context)=> {
            if(SignalingDataStore.DEBUG) {
                console.log("Saw appeared UUID (SignalingDataStore):", context.target);
            }
            if (self.isConceptMapped(context.concept)){
                if (self.inflightChanges.has("appear"+"."+context.target)) return; // This was caused by us, ignore it

                // Broadcast to everyone
                let initialInstance = {
                    q: "notifyInitialInstance",
                    type: context.concept.name,
                    uuid: context.target,
                    properties: {}
                };
                for (let property of Array.from(self.mappedConcepts.get(context.concept.name).keys())){
                    try {
                        initialInstance.properties[property] = await context.concept.getPropertyValue(context.target, property);   
                    } catch (ex){
                        // May not have a getter at all, so skip it
                    }
                }                
                this.backingElement.webstrate.signal(initialInstance);
            }
        }));
    }
    
    async receiveSignal(message, sender){
        if (sender==webstrate.clientId) return; // Ignore our own messages
        if (!message.q) return;
        if (SignalingDataStore.DEBUG) console.log(message, sender);
        switch (message.q){
            case "getConceptUUIDs":
                // A peer is asking us about what UUIDs we know about for a given concept type
                if ((!message.type) || !this.isConceptTypeMapped(message.type)){
                    if (SignalingDataStore.DEBUG) console.log("Ignoring concept listing request since it is not mapped on this peer", message);                    
                    break;
                }
                this.backingElement.webstrate.signal({
                    q: "notifyExists",
                    type: message.type,
                    uuids: await VarvEngine.getAllUUIDsFromType(message.type)
                }, [sender]);
                break;
            case "notifyExists":
                // A peer is telling us about UUIDs it knows for a given concept type
                if ((!message.type) || !this.isConceptTypeMapped(message.type)){
                    if (SignalingDataStore.DEBUG) console.log("Ignoring concept listing notification since it is not mapped on this peer", message);                    
                    break;
                }
                
                let properties = Array.from(this.mappedConcepts.get(message.type).keys());
                for (let uuid of message.uuids){
                    // Check if we already synced this one and only request data discovery if not
                    let ourConceptByUUID = await this.getConceptFromUUID(uuid);
                    if (!ourConceptByUUID){
                        this.backingElement.webstrate.signal({
                            q: "getInitialInstance",
                            uuid: uuid,
                            properties: properties
                        }, [sender]);
                    }
                }
                break;
            case "getInitialInstance":
                if (!(message.uuid&&message.properties)){
                    if (SignalingDataStore.DEBUG) console.log("Ignoring request for initial instance data with malformed request", message);                    
                    break;
                }
                let initialConceptByUUID = await VarvEngine.getConceptFromUUID(message.uuid);
                if (!initialConceptByUUID){
                    if (SignalingDataStore.DEBUG) console.log("Ignoring request for initial instance data for instance that doesn't exist", message);                    
                    break;
                }
                
                // TODO: Check properties are actually mapped
                
                // Construct JSON representation of instance and send it back
                let initialInstanceReply = {
                    q: "notifyInitialInstance",
                    uuid: message.uuid,
                    type: initialConceptByUUID.name,
                    properties: {}
                };
                for (let property of message.properties){
                    initialInstanceReply.properties[property] = await initialConceptByUUID.getPropertyValue(message.uuid, property);   
                }
                this.backingElement.webstrate.signal(initialInstanceReply, [sender]);                
                break;
            case "notifyInitialInstance":
                if (!(message.uuid&&message.properties&&message.type)){
                    if (SignalingDataStore.DEBUG) console.log("Ignoring initial instance data with malformed structure", message);                    
                    break;
                }

                let concept = VarvEngine.getConceptFromType(message.type);
                if (!concept){
                    if (SignalingDataStore.DEBUG) console.log("Ignoring initial instance data with nonexisting concept", message);                    
                    break;
                }
                if (!this.isConceptMapped(concept)){
                    if (SignalingDataStore.DEBUG) console.log("Ignoring initial instance data with non-mapped concept", message);                    
                    break;
                }
                
                
                let varvInitialInstance = await VarvEngine.getConceptFromUUID(message.uuid);
                let usInitialInstance = this.getConceptFromUUID(message.uuid);
                
                // Maybe this was the first time Varv heard about this instance ever, in that case make it appear
                let appearChange = "appear"+"."+message.uuid;
                this.inflightChanges.add(appearChange); // Avoid writebacks from this
                if (!varvInitialInstance) {
                    await concept.appeared(message.uuid);                        
                }                        
                
                if (!usInitialInstance){
                    // This is the first time we signal about this instance but it may be in another local datastore
                    this.registerConceptFromUUID(message.uuid, concept);

                    // Pull the mapped properties from the hivemind to any other local datastore + the concept
                    for (let propertyName of this.mappedConcepts.get(message.type).keys()){
                        try {
                            let property = concept.getProperty(propertyName);                    
                            if (SignalingDataStore.DEBUG) console.log("Loading property", property, message.properties[propertyName]);
                            // TODO: Error if not set in message
                            await this._setVarvProperty(concept, message.uuid, propertyName, message.properties[propertyName]);
                        } catch (ex){
                            console.error("Failed to push concept property from signal to concept", ex);
                        }
                    }                    
                }
                this.inflightChanges.delete(appearChange); // Avoid writebacks from this
                           
                break;
            case "setProperty":
                if (!(message.uuid&&message.property)){
                    if (SignalingDataStore.DEBUG) console.log("Ignoring property change with malformed data", message);                    
                    break;
                }
                
                // TODO: Check if mapped
                let changedConcept = this.getConceptFromUUID(message.uuid);
                if (!changedConcept){
                    if (SignalingDataStore.DEBUG) console.log("Ignoring property change for unknown UUID", message);                    
                    break;
                }
                
                await this._setVarvProperty(changedConcept, message.uuid, message.property, message.value);
                break;
            default:
                console.log("Unhandled signal ",message);
        }
    }
    
    async _setVarvProperty(concept, uuid, propertyName, value){
        let change = uuid+"."+propertyName;
        this.inflightChanges.add(change); // Avoid writebacks from this
        await concept.setPropertyValue(uuid, propertyName, value);
        this.inflightChanges.delete(change);        
    }

    createBackingStore(concept, property) {
        const self = this;
        
        // Check if concept already is mapped, if not, register it
        if (this.isPropertyMapped(concept,property)) return;
        let setter = (uuid, value) => {
            if (self.inflightChanges.has(uuid+"."+property.name)) return;
            self.backingElement.webstrate.signal({
                q: "setProperty",
                uuid: uuid,
                property: property.name,
                value: value
            });
        };
        property.addSetCallback(setter);
        this.internalAddPropertyMapping(concept, property, {setter: setter});
    }    
    
    removeBackingStore(concept, property) {
        if (!this.isPropertyMapped(concept, property)){
            throw new Error('Cannot unmap property from signaling because the property was not mapped: ' + concept + "." + property);
        }

        let trackingData = this.internalPropertyTrackingData(concept, property);
        property.removeSetCallback(trackingData.setter);
        this.internalRemovePropertyMapping(concept, property);
    }
    
    async loadBackingStore() {
        // For each of our stored and mapped concepts, enqueue a list request for UUIDs from the hivemind to populate local state
        for (let conceptType of this.mappedConcepts.keys()){
            this.backingElement.webstrate.signal({
                q: "getConceptUUIDs",
                type: conceptType
            });
        }
    }
}
SignalingDataStore.DEBUG = false;
window.SignalingDataStore = SignalingDataStore;

// Register default dom datastore
Datastore.registerDatastoreType("signaling", SignalingDataStore);