--[[ Archivist - Data management service for WoW Addons Written in 2019 by Allen Faure (emptyrivers) afaure6@gmail.com To the extent possible under law, the author(s) have dedicated all copyright and related and neighboring rights to this software to the public domain worldwide. This software is distributed without any warranty. You should have received a copy of the CC0 Public Domain Dedication along with this software. If not, see http://creativecommons.org/publicdomain/zero/1.0/. ]] local embedder, namespace = ... local addonName, Archivist = "Archivist", {} -- Our only library! local LibDeflate = LibStub("LibDeflate") do -- boilerplate & static values Archivist.buildDate = "@build-time@" Archivist.version = "v1.0.8" --[==[@debug@ Archivist.debug = true --@end-debug@]==] Archivist.prototypes = {} Archivist.storeMap = {} Archivist.activeStores = {} namespace.Archivist = Archivist local unloader = CreateFrame("FRAME") unloader:RegisterEvent("PLAYER_LOGOUT") unloader:SetScript("OnEvent", function() Archivist:DeInitialize() end) if embedder == "Archivist" then -- Archivist is installed as a standalone addon. -- The Archive is in the default location, ACHV_DB _G.Archivist = Archivist local loader = CreateFrame("frame") loader:RegisterEvent("ADDON_LOADED") loader:SetScript("OnEvent", function(self, _, addon) if addon == addonName then if type(ACHV_DB) ~= "table" then ACHV_DB = {} end Archivist:Initialize(ACHV_DB) self:UnregisterEvent("ADDON_LOADED") end end) end end function Archivist:Assert(valid, pattern, ...) if not valid then if pattern then error(pattern:format(...), 2) else error("Archivist encountered an unknown error.", 2) end end end function Archivist:Warn(valid, pattern, ...) -- Like assert, but doesn't interrupt execution if not valid and self.debug then if pattern then print(pattern:format(...), 2) else print("Archivist encountered an unknown warning.") end return true end end function Archivist:IsInitialized() return self.initialized end -- Give Archivist its archive to play with. Called automatically, unless Archivist has been embedded. function Archivist:Initialize(sv) do -- arg validation self:Assert(not self:IsInitialized(), "Archivist has already been initialized.") self:Assert(type(sv) == "table", "Attempt to initialize Archivist SavedVariables with a %q instead of a table.", type(sv)) end self.sv = sv self.initialized = true for id, prototype in pairs(self.prototypes) do self.sv[id] = self.sv[id] or {} if prototype.Init then prototype:Init() end end end -- Shut Archivist down function Archivist:DeInitialize() if self:IsInitialized() then self.initialized = false self:CloseAllStores() self.sv = nil end end -- register a store type with Archivist -- prototype fields: -- id - unique identifier. Preferably also a descriptive name, like "simple" or "snapshot". -- version - positive integer. Used for version control, in case any data migrations are needed. Registration will fail if the prototype is outdated. -- Init - function (optional). If provided, executes exactly once per session, before any other methods are called. -- Create - function (required). Create a brand new active store object. -- Update - function (optional). Massage archived data into a format that Open can accept. Useful for data migrations. -- Open - function (requried). Create from the provided data an active store object. Prototype may assume ownership of the provided data however it wishes. -- Commit - function (required). Return an image of the data that should be archived. -- Close - function (required). Release ownership of active store object. Optionally, return image of data to write into archive. -- Delete - function (optional). If provided, called when a store is deleted. Useful for cleaning up sub stores. -- Please note that Create, Open, Update (if provided), Commit, and Close may be called at any time if Archivist deems it necessary. -- Thus, these methods should ideally be as close to purely functional as is practical, to minimize friction. function Archivist:RegisterStoreType(prototype) do -- prototype validation self:Assert(type(prototype) == "table", "Invalid argument #1 to RegisterStoreType: Expected table, got %q instead.", type(prototype)) -- prototype is now guaranteed to be indexable self:Assert(type(prototype.id) == "string", "Invalid prototype field 'id': Expected string, got %q instead.", type(prototype.id)) self:Assert(type(prototype.version) == "number", "Invalid prototype field 'version': Expected number, got %q instead.", type(prototype.version)) if self:Warn(prototype.version > 0 and prototype.version == math.floor(prototype.version), "Prototype %q version expected to be a positive integer, but got %d instead.", prototype.id, prototype.version) then return end local oldPrototype = self.prototypes[prototype.id] self:Assert(not oldPrototype or prototype.version >= oldPrototype.version, "Store type %q already exists with a higher version", oldPrototype and oldPrototype.version) -- prototype is now guaranteed to be either new or an Update to existing prototype self:Assert(prototype.Init == nil or type(prototype.Init) == "function", "Invalid prototype field 'Init': Expected function, got %q instead.", type(prototype.Init)) self:Assert(type(prototype.Create) == "function", "Invalid prototype field 'Create': Expected function, got %q instead.", type(prototype.Create)) self:Assert(type(prototype.Open) == "function", "Invalid prototype field 'Open': Expected function, got %q instead.", type(prototype.Open)) self:Assert(prototype.Update == nil or type(prototype.Update) == "function", "Invalid prototype field 'Update': Expected function, got %q instead.", type(prototype.Update)) self:Assert(type(prototype.Commit) == "function", "Invalid prototype field 'Commit': Expected function, got %q instead.", type(prototype.Commit)) self:Assert(type(prototype.Close) == "function", "Invalid prototype field 'Close': Expected function, got %q instead.", type(prototype.Close)) self:Assert(prototype.Delete == nil or type(prototype.Delete) == "function", "Invalid prototype field 'Delete': Expected function, got %q instead.", type(prototype.Delete)) -- prototype is now guaranteed to have Init, Create, Open, Update functions, and is thus well-formed. end local oldPrototype = self.prototypes[prototype.id] -- need in case of closing active stores self.prototypes[prototype.id] = { id = prototype.id, version = prototype.version, Init = prototype.Init, Create = prototype.Create, Update = prototype.Update, Open = prototype.Open, Commit = prototype.Commit, Close = prototype.Close, Delete = prototype.Delete } self.activeStores[prototype.id] = self.activeStores[prototype.id] or {} if self:IsInitialized() then self.sv[prototype.id] = self.sv[prototype.id] or {} if prototype.Init then prototype:Init() end -- if prototype was previously registered, and Archivist is initialized, then there may be open stores of the old prototype. -- Close them, Update if necessary, then re-Open them with the new prototype. if oldPrototype then for storeID, store in pairs(self.activeStores[prototype.id]) do local image = oldPrototype:Close(store) local saved = self.sv[prototype.id][storeID] local shouldReArchive = image ~= nil if image == nil then image = saved.data end if prototype.Update then local newImage = prototype:Update(image, saved.version) if newImage ~= nil then image = newImage shouldReArchive = true end saved.version = prototype.version end self.activeStores[prototype.id][storeID] = prototype:Open(image) if shouldReArchive then -- a meaningful change to saved data has occurred. saved.data = self:Archive(image) end end end end end do -- function Archive:GenerateID() -- adapted from https://gist.github.com/jrus/3197011 local function randomHex() return ('%x'):format(math.random(0, 0xf)) end function Archivist:GenerateID() local template ='xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx' return (template:gsub('x', randomHex)) end end -- creates and opens a new store of the given store type and with the given id (if given) -- store objects are lightly managed by Archivist. On PLAYER_LOGOUT, all open stores are Closed, -- and the resultant data is compressed into the archive. function Archivist:Create(storeType, id, ...) do -- arg validation self:Assert(type(storeType) == "string" and self.prototypes[storeType], "Store type must be registered before loading data.") self:Assert(id == nil or type(id) == "string" and not self.sv[storeType][id], "A store already exists with that id. Did you mean to call Archivist:Open?") end local store, image = self.prototypes[storeType]:Create(...) do -- ensure that store exists and is unique self:Assert(store ~= nil, "Failed to create a new store of type %q.", storeType) self:Assert(self.storeMap[store] == nil, "Store Type %q produced an store object already registered with Archivist instead of creating a new one.", storeType) end if id == nil then id = self:GenerateID() end self.activeStores[storeType][id] = store self.storeMap[store] = { id = id, prototype = self.prototypes[storeType], type = storeType } if image == nil then -- save initial image via Commit image = self.prototypes[storeType]:Commit(store) end self:Assert(image ~= nil, "Create Verb failed to generate initial image for archive.") self.sv[storeType][id] = { timestamp = time(), version = self.prototypes[storeType].version, data = self:Archive(image) } return store, id end -- clones archived data and/or active store object to newId -- also provides an active store object of the cloned data if openStore is set function Archivist:Clone(storeType, id, newId, openStore) do -- arg validation self:Assert(type(storeType) == "string" and self.prototypes[storeType], "Store type must be registered to clone a store.") self:Assert(type(id) == "string" and (self.sv[storeType][id] or self.activeStores[storeType][id]), "Unable to clone store: store not found.") end if type(newId) ~= "string" then newId = self:GenerateID() end self:Assert(not self.sv[storeType][newId], "Store with ID %q already exists. Choose a different ID.") if self.activeStores[storeType][id] then -- go ahead and commit active store self:Commit(storeType, id) end -- thankfully, strings are easy to copy self.sv[storeType][newId] = { version = self.prototypes[storeType].version, timestamp = time(), data = self.sv[storeType][id].data } if openStore then return self:Open(storeType, newId), newId else return nil, newId end end function Archivist:CloneStore(store, newId, openStore) self:Assert(self.storeMap[store], "Unrecognized store was provided.") local info = self.storeMap[store] return self:Clone(info.type, info.id, newId, openStore) end -- Closes store (if open), then deletes data from archive -- Prototype is given opportunity to perform actions using image (usually, to delete other sub stores) -- if store type is not registered, then force flag must be set in order to delete data, -- to reduce the chance of accidents function Archivist:Delete(storeType, id, force) do -- arg validation self:Warn(force or type(storeType == "string") and self.sv[storeType], "There are no stores to delete.") self:Assert(force or self.prototypes[storeType], "Store type should be registered before deleting a store. Call Delete again with arg #3 == true to override this.") end if id and storeType and self.sv[storeType] then if self.prototypes[storeType] and self.prototypes[storeType].Delete and self.sv[storeType][id] then local image = self.activeStores[storeType][id] and self:Close(self.activeStores[storeType][id]) or self:DeArchive(self.sv[storeType][id].data) self.prototypes[storeType]:Delete(image) end self.sv[storeType][id] = nil end end function Archivist:DeleteStore(store) self:Assert(self.storeMap[store], "Unrecognized store was provided.") local info = self.storeMap[store] return self:Delete(info.type, info.id) end -- unpacks data in the archive into an active store object -- if store is already active, then returns active store object function Archivist:Open(storeType, id, ...) do -- arg validation self:Assert(type(storeType) == "string" and self.prototypes[storeType], "Store type must be registered before opening a store.") self:Assert(type(id) == "string" and (self.sv[storeType][id] or self.activeStores[storeType][id]), "Could not find a store with that ID. Did you mean to call Archivist:Create?") end local store = self.activeStores[storeType][id] if not store then local saved = self.sv[storeType][id] local data = self:DeArchive(saved.data) local prototype = self.prototypes[storeType] -- migrate data... if prototype.Update and prototype.version > saved.version then local newData = prototype:Update(data, saved.version) if newData ~= nil then saved.data = self:Archive(newData) saved.timestamp = time() end saved.version = prototype.version end -- create store object... store = prototype:Open(data, ...) -- cache it so that we can close it later.. self.activeStores[storeType][id] = store self.storeMap[store] = { id = id, prototype = self.prototypes[storeType], type = storeType } end return store end -- DANGEROUS FUNCTION -- Your data will be lost. All of it. No going back. -- Don't say I didn't warn you function Archivist:DeleteAll(storeType) if storeType then self.sv[storeType] = {} for id, store in pairs(self.activeStores[storeType]) do self.activeStores[storeType][id] = nil self.storeMap[store] = nil end else for id in pairs(self.prototypes) do self.sv[id] = {} self.activeStores[id] = {} end self.storeMap = {} end end -- deactivates store, with one last opportunity to commit data if the prototype chooses to do so function Archivist:Close(storeType, id) do -- arg validation self:Assert(type(storeType) == "string" and self.prototypes[storeType], "Closing a store of an unregistered store type doesn't make sense.") self:Warn(type(id) == "string" and self.activeStores[storeType][id], "No store with that ID can be found.") end local store = self.activeStores[storeType][id] local saved = self.sv[storeType][id] if store then local image = self.prototypes[storeType]:Close(store) if image ~= nil then saved.data = self:Archive(image) saved.timestamp = time() end self.activeStores[storeType][id] = nil self.storeMap[store] = nil end end function Archivist:CloseStore(store) self:Assert(self.storeMap[store], "Unrecognized store was provided.") local info = self.storeMap[store] return self:Close(info.type, info.id) end function Archivist:CloseAllStores() for storeType, prototype in pairs(self.prototypes) do for id, store in pairs(self.activeStores[storeType]) do local image = prototype:Close(store) local saved = self.sv[storeType][id] self.activeStores[storeType] = nil if image then saved.data = self:Archive(image) saved.timestamp = time() end end end end -- archives an image of the store object function Archivist:Commit(storeType, id) do -- arg validation self:Assert(type(storeType) == "string" and self.prototypes[storeType], "Committing a store of an unregistered store type doesn't make sense.") self:Assert(type(id) == "string" and self.activeStores[storeType][id], "No store with that ID can be found.") end local store = self.activeStores[storeType][id] local image = self.prototypes[storeType]:Commit(store) local saved = self.sv[storeType][id] if image ~= nil then saved.data = self:Archive(image) saved.timestamp = time() end end function Archivist:CommitStore(store) self:Assert(self.storeMap[store], "Unrecognized store was provided.") local info = self.storeMap[store] return self:Commit(info.type, info.id) end -- opens or creates a storeType, depending on what is appropriate -- this is the main entry point for other addons who just want their saved data function Archivist:Load(storeType, id) do -- arg validation self:Assert(type(storeType) == "string" and self.prototypes[storeType], "Store type must be registered before loading data.") self:Assert(id == nil or type(id) == "string", "Store ID must be a string if provided.") end if id == nil or not self.sv[storeType][id] then return self:Create(storeType, id) elseif self.activeStores[storeType][id] then return self.activeStores[storeType][id] else return self:Open(storeType, id) end end function Archivist:Check(storeType, id) do -- arg validation self:Assert(type(storeType) == "string", "Expected string for storeType, got %q.", type(storeType)) self:Assert(type(id) == "string", "Expected string for storeID, got %q.", type(id)) end if self.sv[storeType] and self.sv[storeType][id] then return true else return false end end do -- function Archivist:Archive(data) local tinsert, tconcat = table.insert, table.concat -- serialized string looks like -- ,,...,, -- (in most cases will be just &1) -- is a series of 0 or more ^: pairs -- the contents of the string between ^ or : and the next magic character is a string, -- unless the first char is the magic #, in which case it is a number. -- @ becomes boolean true, $ becomes false -- &N is a reference to -- when deserializing, the result of is our result local function replace(c) return "\\"..c end local function serialize(object) local seenObjects = {} local serializedObjects = {} local function inner(val) local valType = type(val) if valType == "boolean" then return val and "@" or "$" elseif valType == "number" then return "#" .. val elseif valType == "string" then -- escape all characters that might be confused as magic otherwise return (val:gsub("[\\&,^@$#:]", replace)) elseif valType == "table" then if not seenObjects[val] then -- cross referencing is a thing. Not to hard to serialize but do be careful local index = #serializedObjects + 1 seenObjects[val] = index local serialized = {} serializedObjects[index] = "" -- so that later inserts go to the correct spot for k,v in pairs(val) do local key, value = inner(k), inner(v) if key ~= nil and value ~= nil then tinsert(serialized, "^" .. inner(k)) tinsert(serialized, ":" .. inner(v)) end end serializedObjects[index] = tconcat(serialized) end return "&" .. seenObjects[val] end end tinsert(serializedObjects, inner(object)) -- ensure that serialized data ends with a comma tinsert(serializedObjects, "") return tconcat(serializedObjects, ',') end function Archivist:Archive(data) local serialized = serialize(data) local compressed = LibDeflate:CompressDeflate(serialized) local encoded = LibDeflate:EncodeForPrint(compressed) return encoded end end do -- function Archivist:DeArchive(encoded) local escape2unused = { ["\\"] = "\001", ["&"] = "\002", [","] = "\003", ["^"] = "\004", ["@"] = "\005", ["$"] = "\006", ["#"] = "\007", [":"] = "\008", } local unused2Escape = tInvert(escape2unused) local unused = "[\001-\008]" local function unusify(c) return escape2unused[c] or c end local function escapify(c) return unused2Escape[c] or c end local function parse(value, objectList) local firstChar = value:sub(1,1) local remainder = value:sub(2) if firstChar == "@" then return true, "BOOL", remainder elseif firstChar == "$" then return false, "BOOL", remainder elseif firstChar == "#" then local num, rest = remainder:match("([^\\&,^@$#:]*)(.*)") return tonumber(num), "NUMBER", rest elseif firstChar == "^" then local str, rest = remainder:match("([^:^,]*)(.*)") local key = parse(str, objectList) return key, "KEY", rest elseif firstChar == ":" then local str, rest = remainder:match("([^:^,]*)(.*)") local val = parse(str, objectList) return val, "VALUE", rest elseif firstChar == "&" then local num, rest = remainder:match("([^\\&,^@$#:]*)(.*)") return objectList[tonumber(num)], "OBJECT", rest else local str, rest = value:match("([^\\&,^@$#:]*)(.*)") return str:gsub(unused, escapify), "STRING", rest end end local function deserialize(value) -- first, convert escaped magic characters to chars that we'll likely never find naturally value = value:gsub("\\([\\&,^@$#:])", unusify) -- then, split by comma to get a list of objects local serializedObjects = {} for piece in value:gmatch("([^,]*),") do table.insert(serializedObjects, piece) end local objects = {} -- create one empty object for each object in the list for i = 1, #serializedObjects - 1 do objects[i] = {} end for index = 1, #serializedObjects - 1 do local str = serializedObjects[index] local object = objects[index] local mode = "KEY" local key local newValue, valueType while #str > 0 do newValue, valueType, str = parse(str, objects) Archivist:Assert(valueType == mode, "Encountered unexpected token type while parsing object. Expected %q but got %q.", mode, valueType) if valueType == "KEY" then key = newValue mode = "VALUE" else mode = "KEY" object[key] = newValue end end Archivist:Assert(mode == "KEY", "Encountered end of serialized token unexpectedly.") end local deserialized, _, remainder = parse(serializedObjects[#serializedObjects], objects) Archivist:Assert(#remainder == 0, "Unexpected token at end of serialized string. Expected EOF, got %q.", remainder:sub(1,10)) return deserialized end function Archivist:DeArchive(encoded) local compressed = LibDeflate:DecodeForPrint(encoded) local serialized = LibDeflate:DecompressDeflate(compressed) local data = deserialize(serialized) return data end end