From c92b25b392091cc8bb64857eb0d0c418245de38d Mon Sep 17 00:00:00 2001 From: kalrnlo Date: Wed, 7 Aug 2024 00:33:13 -0400 Subject: [PATCH] Update chest --- libs/chest/README.md | 1 + libs/chest/callbacks.luau | 40 ++++++ libs/chest/data_ops.luau | 52 +++---- libs/chest/gem/base.luau | 28 ++++ libs/chest/gem/get_keyinfo.luau | 22 +++ libs/chest/{gems => gem}/global.luau | 0 libs/chest/gem/local.luau | 199 +++++++++++++++++++++++++++ libs/chest/gems/base.luau | 0 libs/chest/gems/local.luau | 0 libs/chest/migrate.luau | 8 +- libs/chest/raw_update.luau | 27 ++-- libs/chest/session.luau | 31 +++++ libs/cross/init.luau | 8 +- 13 files changed, 366 insertions(+), 50 deletions(-) create mode 100644 libs/chest/callbacks.luau create mode 100644 libs/chest/gem/base.luau create mode 100644 libs/chest/gem/get_keyinfo.luau rename libs/chest/{gems => gem}/global.luau (100%) create mode 100644 libs/chest/gem/local.luau delete mode 100644 libs/chest/gems/base.luau delete mode 100644 libs/chest/gems/local.luau create mode 100644 libs/chest/session.luau diff --git a/libs/chest/README.md b/libs/chest/README.md index e69de29..99b960a 100644 --- a/libs/chest/README.md +++ b/libs/chest/README.md @@ -0,0 +1 @@ +# [Documentation](https://libs.luau.lol/chest) diff --git a/libs/chest/callbacks.luau b/libs/chest/callbacks.luau new file mode 100644 index 0000000..69528b4 --- /dev/null +++ b/libs/chest/callbacks.luau @@ -0,0 +1,40 @@ +--!native + +-- callbacks +-- utility for handling callbacks + +local cross = require("libs/cross") + +local callbacks = {} + +function callbacks.SPAWN(callbacks: { { (A...) -> () } }, ...: A...) + for _, callback_info in callbacks do + cross.spawn(callback_info[1], ...) + end +end + +function callbacks.CONNECTION( + callbacks: { { (A...) -> () } }, + callback: (A...) -> () +): () -> () + local info = table.freeze({ callback }) + table.insert(callbacks, info) + + return function() + local index = table.find(callbacks, info) + + if index then + if index ~= 1 then + local len = #callbacks + callbacks[index] = callbacks[len] + callbacks[len] = nil + else + callbacks[1] = nil + end + end + end +end + +return table.freeze(callbacks) + + diff --git a/libs/chest/data_ops.luau b/libs/chest/data_ops.luau index 0b0f326..21b7243 100644 --- a/libs/chest/data_ops.luau +++ b/libs/chest/data_ops.luau @@ -13,7 +13,7 @@ local function CLONE_BUFFER(buf: buffer): buffer end -- merges a & b into one array, eliminating duplicate values -local function merge_arrays_unique_only(a: { V }, b: { V }): { V } +local function MERGE_ARRAYS_UNIQUE_ONLY(a: { V }, b: { V }): { V } local result_tbl = table.clone(a) for _, value in b do @@ -24,23 +24,23 @@ local function merge_arrays_unique_only(a: { V }, b: { V }): { V } return result_tbl end -local function deepfreeze(tbl: T & {}): T +local function DEEP_FREEZE(tbl: T & {}): T local tbl = tbl :: any for index, value in tbl do if type(value) == "table" then - tbl[index] = deepfreeze(value :: any) + tbl[index] = DEEP_FREEZE(value :: any) end end return table.freeze(tbl) :: any end -local function frozen_deepclone(tbl: T & {}): T +local function FROZEN_DEEPCLONE(tbl: T & {}): T local clone = table.clone(tbl) for index, value in clone do if type(value) == "table" then - clone[index] = frozen_deepclone(value :: any) + clone[index] = FROZEN_DEEPCLONE(value :: any) elseif type(value) == "buffer" then clone[index] = CLONE_BUFFER(value) end @@ -48,12 +48,12 @@ local function frozen_deepclone(tbl: T & {}): T return table.freeze(clone) :: any end -local function deepclone(tbl: T & {}): T +local function DEEPCLONE(tbl: T & {}): T local clone = table.clone(tbl) for index, value in clone do if type(value) == "table" then - clone[index] = deepclone(value :: any) + clone[index] = DEEPCLONE(value :: any) elseif type(value) == "buffer" then clone[index] = CLONE_BUFFER(value) end @@ -61,24 +61,16 @@ local function deepclone(tbl: T & {}): T return clone :: any end -local function clone_val(value: Value): Value +local function CLONE_VAL(value: Value): Value return if type(value) == "table" then - deepclone(value) + DEEPCLONE(value) elseif type(value) == "buffer" then CLONE_BUFFER(value) :: any else value end -local function clone_default_val( - chest: t.Chest, - key: string -): Value - local getter = chest.default_value_getter - return if getter then getter(key) else clone_val(chest.default_value :: any) -end - -local function reconcile(original: { [any]: any }, template: T & {}): T +local function RECONCILE(original: { [any]: any }, template: T & {}): T local tbl = table.clone(original) local template = template :: any @@ -86,30 +78,30 @@ local function reconcile(original: { [any]: any }, template: T & {}): T local tbl_key = tbl[key] if not tbl_key then - tbl[key] = clone_val(value) + tbl[key] = CLONE_VAL(value) elseif type(tbl_key) == "table" and type(value) == "table" then - tbl[key] = reconcile(tbl_key, value) + tbl[key] = RECONCILE(tbl_key, value) end end return tbl :: any end -local function clone_default_val( +local function CLONE_DEFAULT_VAL( chest: t.Chest, key: string ): Value local getter = chest.default_value_getter - return if getter then getter(key) else clone_val(chest.default_value :: any) + return if getter then getter(key) else CLONE_VAL(chest.default_value :: any) end return table.freeze({ - merge_arrays_unique_only = merge_arrays_unique_only, - clone_default_val = clone_default_val, - frozen_deepclone = frozen_deepclone, - clone_buf = CLONE_BUFFER, - deepfreeze = deepfreeze, - deepclone = deepclone, - clone_val = clone_val, - reconcile = reconcile, + MERGE_ARRAYS_UNIQUE_ONLY = MERGE_ARRAYS_UNIQUE_ONLY, + CLONE_DEFAULT_VAL = CLONE_DEFAULT_VAL, + FROZEN_DEEPCLONE = FROZEN_DEEPCLONE, + CLONE_BUF = CLONE_BUFFER, + DEEP_FREEZE = DEEP_FREEZE, + DEEPCLONE = DEEPCLONE, + CLONE_VAL = CLONE_VAL, + RECONCILE = RECONCILE, }) \ No newline at end of file diff --git a/libs/chest/gem/base.luau b/libs/chest/gem/base.luau new file mode 100644 index 0000000..df6900a --- /dev/null +++ b/libs/chest/gem/base.luau @@ -0,0 +1,28 @@ + +-- base gem +-- class that the other gems inherit from as both classes share +-- the exact same methods for dealing w datastore versioning + +-- NOTE: do when DataStore:GetVersionAtTimeAsync() is added +-- https://devforum.roblox.com/t/upcoming-changes-to-data-stores-versioning/3042258 + +local t = require("../types") + +local basegem_proto = {} :: t.BaseGemPrototype +(basegem_proto :: any).__index = basegem_proto + +basegem_proto.revert = function(gem: t.BaseGem, time: number, auto_migrate: boolean?): (boolean, string?) + local gem: t.GlobalGem | t.Gem = gem :: any + local chest = gem.chest + + error("[CHEST] method not implemented, waiting for DataStore:GetVersionAtTimeAsync() to be added") +end :: any + +basegem_proto.datastore_version = function(gem: t.BaseGem, time: number): (boolean, string?) + local gem: t.GlobalGem | t.Gem = gem :: any + local chest = gem.chest + + error("[CHEST] method not implemented, waiting for DataStore:GetVersionAtTimeAsync() to be added") +end :: any + +return table.freeze(basegem_proto) \ No newline at end of file diff --git a/libs/chest/gem/get_keyinfo.luau b/libs/chest/gem/get_keyinfo.luau new file mode 100644 index 0000000..519d11b --- /dev/null +++ b/libs/chest/gem/get_keyinfo.luau @@ -0,0 +1,22 @@ +--!native + +-- get keyinfo +-- function for getting the keyinfo for a gem + +local t = require("../types") + +local ERR_CANNOT_FIND_GEM = "[CHEST] cannot %s gem %s because it doesnt exist in chest %s" + +local function GET_KEYINFO(local_gem: t.Gem, error_action: string): t.KeyInfo + local chest = local_gem.chest + local name = local_gem.name + local keyinfo = chest.local_keys[name] + + if keyinfo then + return keyinfo :: any + else + error(string.format(ERR_CANNOT_FIND_GEM, error_action, name, chest.name), 2) + end +end + +return GET_KEYINFO \ No newline at end of file diff --git a/libs/chest/gems/global.luau b/libs/chest/gem/global.luau similarity index 100% rename from libs/chest/gems/global.luau rename to libs/chest/gem/global.luau diff --git a/libs/chest/gem/local.luau b/libs/chest/gem/local.luau new file mode 100644 index 0000000..cf9930e --- /dev/null +++ b/libs/chest/gem/local.luau @@ -0,0 +1,199 @@ + +-- local gem +-- class for keys that the server has ownership over + +local DEEP_ENSURE_UTF8 = require("../deep_ensure_utf8") +local RAW_UPDATE = require("../raw_update") +local GET_KEYINFO = require("get_keyinfo") +local CALLBACKS = require("../callbacks") +local DATA_OPS = require("../data_ops") +local SESSION = require("../session") +local cross = require("libs/cross") +local t = require("../types") +local base = require("base") + +local gem = (setmetatable({}, base) :: any) :: t.GemPrototype +(gem :: any).__index = gem + +local function CALL_NO_YIELD(f: (A...) -> R..., ...: A...): (boolean, R...) + local rets + local thread = cross.spawn(function(...: A...) + rets = { pcall(f, ...) } + end, ...) + + if coroutine.status(thread) ~= "dead" then + warn(debug.traceback(thread, "[CHEST] transform callbacks cannot yield")) + return false + else + local success = rets[1] + + if success then + return true, unpack(rets :: any, 2) + else + warn(debug.traceback(thread, "[CHEST] an error occured when calling a transform callback")) + return false + end + end +end + +local function RUN_QUEUE_FOR_KEYINFO(keyinfo: t.KeyInfo): { thread } + local threads_to_resume = keyinfo.threads_to_spawn_after_save + local update_queue = keyinfo.update_queue + local value = keyinfo.value + + for _, operation in update_queue do + local args = operation.args + local did_yield + local new_value + + local cloned_value = DATA_OPS.CLONE_VAL(value) + + if args then + did_yield, new_value = CALL_NO_YIELD( + operation.callback, cloned_value, unpack(args :: any) + ) + else + did_yield, new_value = CALL_NO_YIELD( + operation.callback, cloned_value + ) + end + + if new_value and not did_yield then + keyinfo.value = new_value + value = new_value + end + end + + table.clear(update_queue) + return threads_to_resume +end + +local function save_local_keyinfo(chest: t.Chest, key: string, keyinfo: t.KeyInfo): (boolean, string?) + keyinfo.is_saving = true + RUN_QUEUE_FOR_KEYINFO(keyinfo) + + local threads_to_resume = keyinfo.threads_to_spawn_after_save + local saveinfo = keyinfo.saveinfo + local userids = keyinfo.userids + local value = keyinfo.value + + local is_valid_utf8 = DEEP_ENSURE_UTF8(value) + + if not is_valid_utf8 then + warn(`[CHEST] failed to save gem {key} in chest {chest.name} due to the value having invalid utf8`) + return false, "invalid utf8" + end + + CALLBACKS.SPAWN(keyinfo.callbacks.on_save, DATA_OPS.CLONE_VAL(value)) + + local success, posssible_error = RAW_UPDATE.SAFE( + chest, key, + function( + global_value, global_saveinfo, global_userids, + our_value: t.JSONAcceptable, our_saveinfo: t.SaveInfo, our_userids: { number } + ) + if not SESSION.CAN_SAVE(global_saveinfo, our_saveinfo.lock_uuid) then + return nil + end + local global_update_info = global_saveinfo.global_update_info + + if global_update_info then + if global_update_info.value_changed then + our_value = DATA_OPS.RECONCILE(global_value :: any, our_value :: any) + end + + if global_update_info.userids_changed and global_userids then + our_userids = DATA_OPS.MERGE_ARRAYS_UNIQUE_ONLY(our_userids, global_userids) + end + end + + our_saveinfo.timestamp = os.time() + return our_value, our_saveinfo, our_userids + end, + value, saveinfo, userids + ) + local error: string? = if not success then posssible_error :: any else nil + + if error then + warn(`[CHEST] failed to save gem {key} in chest {chest.name}\n\tupdate-async-err: {error}`) + end + + for _, thread in threads_to_resume do + task.spawn(thread, success, error) + end + + table.clear(threads_to_resume) + keyinfo.last_save = os.time() + keyinfo.is_saving = false + return success, error +end + +function gem.transform(gem, transform, ...) + local keyinfo = GET_KEYINFO(gem, "transform") + + table.insert(keyinfo.update_queue, table.freeze({ + args = table.freeze({ ... }), + callback = transform, + })) + return gem +end + +function gem.on_close(gem, f) + local keyinfo = GET_KEYINFO(gem, "add on_close callback to") + return CALLBACKS.CONNECTION(keyinfo.callbacks.on_close, f) +end + +function gem.on_save(gem, f) + local keyinfo = GET_KEYINFO(gem, "add on_save callback to") + return CALLBACKS.CONNECTION(keyinfo.callbacks.on_save, f) +end + +function gem.detach_user(gem, userid) + local keyinfo = GET_KEYINFO(gem, "detach userid from") + local userids = keyinfo.userids + local index = table.find(userids, userid) + + if index then + table.remove(userids, index) + end + return gem +end + +function gem.attach_user(gem, userid) + local keyinfo = GET_KEYINFO(gem, "attach userid to") + table.insert(keyinfo.userids, userid) + return gem +end + +function gem.users(gem) + return table.clone( + GET_KEYINFO(gem, "get userids for").userids + ) +end + +function gem.value(gem) + return DATA_OPS.CLONE_VAL( + GET_KEYINFO(gem, "get value for").value + ) +end + +gem.save = function(gem: t.Gem): (boolean, string?) + local keyinfo = GET_KEYINFO(gem, "save") + + if keyinfo.is_saving then + table.insert(keyinfo.threads_to_spawn_after_save, coroutine.running()) + return coroutine.yield() + else + return save_local_keyinfo(gem.chest, gem.name, keyinfo) + end +end :: any + +local function create(chest: t.Chest, name: string, version: number): t.Gem + return table.freeze(setmetatable({ + version = version, + chest = chest, + name = name, + }, gem)) :: any +end + +return create \ No newline at end of file diff --git a/libs/chest/gems/base.luau b/libs/chest/gems/base.luau deleted file mode 100644 index e69de29..0000000 diff --git a/libs/chest/gems/local.luau b/libs/chest/gems/local.luau deleted file mode 100644 index e69de29..0000000 diff --git a/libs/chest/migrate.luau b/libs/chest/migrate.luau index 4c09c33..095ddbd 100644 --- a/libs/chest/migrate.luau +++ b/libs/chest/migrate.luau @@ -3,10 +3,10 @@ -- migrate -- function for handling migrating save data -local data_ops = require("data_ops") +local DATA_OPS = require("data_ops") local t = require("types") -local function migrate( +local function MIGRATE( chest: t.Chest, saveinfo: t.SaveInfo, key: string, @@ -19,7 +19,7 @@ local function migrate( local success if not save_version then - return "SUCCESS", data_ops.clone_default_val(chest, key) + return "SUCCESS", DATA_OPS.CLONE_DEFAULT_VAL(chest, key) elseif save_version > our_version then -- COULD NOT LOAD VALUE ERROR HERE return "SAVE VERSION TOO HIGH", nil @@ -44,4 +44,4 @@ local function migrate( return "SUCCESS", new_value end -return migrate \ No newline at end of file +return MIGRATE \ No newline at end of file diff --git a/libs/chest/raw_update.luau b/libs/chest/raw_update.luau index fdf343a..fe7a2ee 100644 --- a/libs/chest/raw_update.luau +++ b/libs/chest/raw_update.luau @@ -5,23 +5,23 @@ local retryer = require("libs/retryer") local t = require("types") -type RawUpdate = typeof(setmetatable({} :: { retry: RawUpdateFn }, {} :: RawUpdatePrototype)) - type RawUpdateFn = ( chest: t.Chest, key: string, f: t.RawUpdateAsyncCallback, A... ) -> (boolean, Value, DataStoreKeyInfo) -type RawUpdatePrototype = { - __call: ( - self: S, chest: t.Chest, key: string, - f: t.RawUpdateAsyncCallback, A... - ) -> (boolean, Value, DataStoreKeyInfo) +type RawUpdate = { + RETRY: RawUpdateFn, + SAFE: RawUpdateFn, + UNSAFE: ( + chest: t.Chest, key: string, + f: t.RawUpdateAsyncCallback, + A... + ) -> (Value, DataStoreKeyInfo) } -local raw_update = {} :: { retry: RawUpdateFn } -local raw_update_mt = {} :: RawUpdatePrototype +local raw_update = {} :: RawUpdate -- exists so the typechecker doesnt yell at me local function PACK_VARADIC(...: any): { any } @@ -54,12 +54,12 @@ local function RAW_UPDATE_ASYNC( ) end -function raw_update_mt.__call(self, chest, key, f, ...) +function raw_update.SAFE(chest, key, f, ...) -- type error: generic subtype escaping scope occurs if i dont typecast chest return pcall(RAW_UPDATE_ASYNC, chest :: any, key, f, ...) end -function raw_update.retry(chest, key, f, ...) +function raw_update.RETRY(chest, key, f, ...) -- same type error here sigh.. return retryer.exp( chest.time_between_attempts, chest.retry_exponent, @@ -68,5 +68,6 @@ function raw_update.retry(chest, key, f, ...) ) end -table.freeze(setmetatable(raw_update, table.freeze(raw_update_mt))) -return (raw_update :: any) :: RawUpdate \ No newline at end of file +raw_update.UNSAFE = RAW_UPDATE_ASYNC + +return table.freeze(raw_update) \ No newline at end of file diff --git a/libs/chest/session.luau b/libs/chest/session.luau new file mode 100644 index 0000000..a91eb77 --- /dev/null +++ b/libs/chest/session.luau @@ -0,0 +1,31 @@ +--!native + +-- session +-- util for dealing with session locks + +local t = require("types") + +local SESSION = {} + +local function IS_EXPIRED(timestamp: number) + return os.time() - timestamp >= 60 +end + +function SESSION.CAN_SAVE(saveinfo: t.SaveInfo, our_uuid: string): boolean + local is_expired = IS_EXPIRED(saveinfo.timestamp) + + return saveinfo.lock_uuid == our_uuid -- Case 1: Locked by us + or (saveinfo.is_locked and is_expired) -- Case 2: Locked, expired + or is_expired -- Case 3: expired +end + +function SESSION.CAN_LOCK(saveinfo: t.SaveInfo): boolean + local is_expired = IS_EXPIRED(saveinfo.timestamp or 0) + local is_locked = saveinfo.is_locked + + return not is_locked -- Case 1: Not locked + or (is_locked and is_expired) -- Case 2: Locked, expired + or is_expired -- Case 3: expired +end + +return table.freeze(SESSION) \ No newline at end of file diff --git a/libs/cross/init.luau b/libs/cross/init.luau index b84c458..f4e6f7f 100644 --- a/libs/cross/init.luau +++ b/libs/cross/init.luau @@ -3,6 +3,8 @@ -- cross -- cross runtime utility +type Spawn = (f: ((A...) -> (R...)) | thread, A...) -> thread + local IS_ROBLOX = Instance and Instance.new and game and game.Genre local IS_LUNE = string.find(_VERSION, "Lune") ~= nil -- putting paraentheses around require removes its magic function @@ -28,10 +30,10 @@ return table.freeze({ lune = IS_LUNE, purespawn = PURE_SPAWN, - spawn = if task then - task.spawn + spawn = (if task then + task.spawn elseif IS_LUNE then REQUIRE("@lune/task").spawn else - PURE_SPAWN, + PURE_SPAWN) :: Spawn, }) \ No newline at end of file