/**
* WSDataDataStore - stores properties in .data on a compatible webstrate
*
* 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 uses Webstrates Data API as the storage.
*
* This datastore registers as the type "wsdata".
*
* ### Options
* * "storageName" - The name of the data bucket you intend to use inside automerge.contentDoc.data (default: "varvData")
*
* @memberOf Datastores
* @example
* <caption>Adding the datastore</caption>
* {
* "dataStores": {
* "myDataStore": {
* "type": "wsdata",
* "options": {
* "storageName": "myData"
* }
* },
* ...
* },
* ...
* @example
* <caption>Using the datastore as default store globally</caption>
* {
* "defaultMappings": ["wsdata", "cauldron]
* }
*/
class WSDataDataStore 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;
self.storageName = "varvData";
if(this.options.storageName != null) {
self.storageName = this.options.storageName;
}
// Try to find/create the object to store instances in
if (typeof webstrate === "undefined" ||
typeof webstrate.updateData === "undefined" ||
typeof webstrate.on === "undefined" ||
typeof automerge === "undefined" ||
typeof automerge.contentDoc === "undefined" ||
typeof automerge.contentDoc.data === "undefined"){
throw new Error("Cannot use wsdata datastore on page without automerge.contentDoc.data and websstrate.updateData");
}
if (typeof automerge.contentDoc.data[self.storageName] === "undefined"){
await webstrate.updateData((data)=>{
data[self.storageName] = {};
});
}
// Register for updates
let updateFunction = (patch)=>self.onDataUpdated(patch);
webstrate.on("dataChangedWithPatchSet", updateFunction);
this.deleteCallbacks.push({
delete: ()=>{webstrate.off("dataChangedWithPatchSet", updateFunction);}
});
this.deleteCallbacks.push(VarvEngine.registerEventCallback("disappeared", async (context)=> {
if(WSDataDataStore.DEBUG) {
console.log("Saw disappeared UUID (WSDataDataStore):", context.target);
}
if (self.isConceptMapped(context.concept)){
await webstrate.updateData((data)=>{
delete data[self.storageName][context.concept.name][context.target];
});
}
}));
this.deleteCallbacks.push(VarvEngine.registerEventCallback("appeared", async (context)=> {
if(WSDataDataStore.DEBUG) {
console.log("Saw appeared UUID (WSDataDataStore):", context);
}
if (self.isConceptMapped(context.concept)){
if (self.inflightChanges.has("appear"+"."+context.target)) return; // This was caused by us, ignore it
if (typeof automerge.contentDoc.data[self.storageName][context.concept.name][context.target] !== "undefined") return; // This already exists
if(WSDataDataStore.DEBUG) {
console.log("Writing appeared UUID (WSDataDataStore):", context.target);
}
// Broadcast to everyone
let initialProperties = {}
for (let property of Array.from(self.mappedConcepts.get(context.concept.name).keys())){
try {
initialProperties[property] = await context.concept.getPropertyValue(context.target, property);
} catch (ex){
// May not have a getter/setter at all, so skip it
}
}
await webstrate.updateData((data)=>{
data[self.storageName][context.concept.name][context.target] = initialProperties;
});
}
}));
}
async onDataUpdated(patches){
let self = this;
if (WSDataDataStore.DEBUG) console.log(patches);
for (let patch of patches){
if (patch.path.length<1) continue;
if (patch.path[0]===this.storageName){ // Only look at dat in our storage bucket storageName
if (patch.shouldSkip) continue;
if (patch.path.length<3) continue;
let type = patch.path[1];
let uuid = patch.path[2];
let concept = VarvEngine.getConceptFromType(type);
if (!self.isConceptTypeMapped(type)) {
if (WSDataDataStore.DEBUG) console.log("Patch with unmapped type", type, patch);
continue;
}
if (patch.path.length===3){ // This targets a concept
switch (patch.action){
case "del": // Concept was deleted
if (WSDataDataStore.DEBUG) console.log("Remote deleted instance", type, uuid);
await concept.disappeared(uuid);
// TODO: Read forward and mark all related patches as skip
break;
case "put": // Concept was added or completely overwritten
if (self.inflightChanges.has("appear"+"."+uuid)) return; // This was caused by us, ignore it
// Check if already registered and only generate an appear event if not
let conceptByUUID = await VarvEngine.getConceptFromUUID(uuid);
if (conceptByUUID){
if (WSDataDataStore.DEBUG) console.log("Ignoring that remote added instance that already exists", type, uuid);
continue;
}
if (WSDataDataStore.DEBUG) console.log("Remote added instance", type, uuid);
// TODO: Read forward and bulk set properties
await self._generateAppear(concept, uuid);
break;
default:
console.log("FIXME: Unknown concept action in wsdata", patch);
}
} else { // This targets a property
let propertyName = patch.path[3];
if (!self.isPropertyNameMapped(type,propertyName)){
if (WSDataDataStore.DEBUG) console.log("Patch with unmapped property", type, propertyName, patch);
continue;
}
if (self.inflightChanges.has(uuid+"."+propertyName)) return; // This was caused by us, ignore it
if (WSDataDataStore.DEBUG) console.log("Patch changed property", type, propertyName, patch);
await self._setVarvProperty(concept, uuid, propertyName, structuredClone(automerge.contentDoc.data[self.storageName][type][uuid][propertyName]));
}
}
}
}
async createBackingStore(concept, property) {
const self = this;
// Check if concept already is mapped, if not, register it
if (this.isPropertyMapped(concept,property)) return;
if (typeof automerge.contentDoc.data[self.storageName][concept.name]==="undefined"){
if(WSDataDataStore.DEBUG) {
console.log("Adding type space to data for:", concept.name);
}
await webstrate.updateData((data)=>{
data[self.storageName][concept.name] = {};
});
}
let setter = async (uuid, value) => {
if (self.inflightChanges.has(uuid+"."+property.name)) return; // Avoid writebacks from our own changes
if(WSDataDataStore.DEBUG) {
console.log("Setting ",property.name);
}
await webstrate.updateData((data)=>{
data[self.storageName][concept.name][uuid][property.name]=value;
});
};
let getter = (uuid, value) => {
return automerge.contentDoc.data[self.storageName][concept.name][uuid][property.name];
};
property.addGetCallback(getter);
property.addSetCallback(setter);
this.internalAddPropertyMapping(concept, property, {setter: setter, getter: getter});
}
removeBackingStore(concept, property) {
if (!this.isPropertyMapped(concept, property)){
throw new Error('Cannot unmap property from wsdata because the property was not mapped: ' + concept + "." + property);
}
let trackingData = this.internalPropertyTrackingData(concept, property);
property.removeSetCallback(trackingData.setter);
this.internalRemovePropertyMapping(concept, property);
}
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);
}
async _generateAppear(concept, uuid){
let appearChange = "appear"+"."+uuid;
this.registerConceptFromUUID(uuid, concept);
this.inflightChanges.add(appearChange); // Avoid writebacks from this
await concept.appeared(uuid);
this.inflightChanges.delete(appearChange); // Avoid writebacks from this
}
async loadBackingStore() {
// For each of our mapped concepts, popuplate local state from the corresponding stored data objects
let self = this;
for (let [type,instances] of Object.entries(automerge.contentDoc.data[self.storageName])){
if (!self.isConceptTypeMapped(type)){
if (WSDataDataStore.DEBUG) console.log("Ignoring concept type from wsdata since it is not mapped", type, instances);
continue;
}
if (instances){
let concept = VarvEngine.getConceptFromType(type);
for (let [uuid,storedConcept] of Object.entries(instances)){
// Check if already registered and only generate an appear event if not
let conceptByUUID = await VarvEngine.getConceptFromUUID(uuid);
if (WSDataDataStore.DEBUG) console.log("Loading concept", type, uuid);
// Set the properties that are mapped
for (const [propertyName,value] of Object.entries(storedConcept)){
try {
if (WSDataDataStore.DEBUG) console.log("Loading property", propertyName, value);
if (self.isPropertyNameMapped(type, propertyName)){
await self._setVarvProperty(concept, uuid, propertyName, structuredClone(value));
}
} catch (ex){
console.error("Failed to push concept property from wsdata to concept", ex);
}
}
if (!conceptByUUID) {
// This was the first time Varv heard about this instance ever, in that case make it appear
self._generateAppear(concept, uuid);
}
}
}
}
}
}
WSDataDataStore.DEBUG = false;
window.WSDataDataStore = WSDataDataStore;
// Register default dom datastore
Datastore.registerDatastoreType("wsdata", WSDataDataStore);