diff --git a/src/__tests__/sync/init.spec.luau b/src/__tests__/sync/init.spec.luau new file mode 100644 index 0000000..57649b9 --- /dev/null +++ b/src/__tests__/sync/init.spec.luau @@ -0,0 +1,11 @@ +return function() + local previous = _G.__DEV__ + + beforeAll(function() + _G.__DEV__ = true + end) + + afterAll(function() + _G.__DEV__ = previous + end) +end diff --git a/src/__tests__/sync/validate.spec.luau b/src/__tests__/sync/validate.spec.luau new file mode 100644 index 0000000..780bf84 --- /dev/null +++ b/src/__tests__/sync/validate.spec.luau @@ -0,0 +1,30 @@ +local validate = require(script.Parent.Parent.Parent.sync.validate) + +return function() + it("validates inputs", function() + local cases = { + array = { value = { 1, 2, 3 }, expected = true }, + array_sparse = { value = { 1, nil :: any, 3 }, expected = true }, + array_sparse_invalid = { value = { [1] = 0, [3] = 0 }, expected = false }, + + map_decimal_key = { value = { [1.5] = 0 }, expected = false }, + map_mixed_key = { value = { [1] = 0, a = 0 }, expected = false }, + + number = { value = 1, expected = true }, + number_infinity = { value = math.huge, expected = false }, + number_nan = { value = 0 / 0, expected = false }, + } + + for key, case in pairs(cases) do + local success, result = pcall(function() + validate(case.value, key, error) + end) + + if case.expected and not success then + error(`Expected {key} to be valid, threw {result}`) + elseif not case.expected and success then + error(`Expected {key} to be invalid, but it was valid`) + end + end + end) +end diff --git a/src/sync/patch.luau b/src/sync/patch.luau index b912ef3..2dca41a 100644 --- a/src/sync/patch.luau +++ b/src/sync/patch.luau @@ -1,3 +1,5 @@ +local validate = require(script.Parent.validate) + local NONE = { __none = "__none" } local function diff(previousState: { [any]: any }, nextState: { [any]: any }) @@ -15,6 +17,15 @@ local function diff(previousState: { [any]: any }, nextState: { [any]: any }) end end + if _G.__DEV__ then + for key, value in previousState do + validate(value, key, warn) + end + for key, value in nextState do + validate(value, key, warn) + end + end + return patch end diff --git a/src/sync/validate.luau b/src/sync/validate.luau new file mode 100644 index 0000000..f76e8cd --- /dev/null +++ b/src/sync/validate.luau @@ -0,0 +1,71 @@ +local isAtom = require(script.Parent.Parent.isAtom) + +local SAFE_KEYS = { string = true, number = true } +local SAFE_VALUES = { string = true, number = true, boolean = true, table = true } + +local function isUnsafeTable(object: { [any]: any }) + local keyType = nil + local objectSize = 0 + + -- All keys must have the same type + for key in object do + local currentType = type(key) + + if not keyType and SAFE_KEYS[currentType] then + keyType = currentType + elseif keyType ~= currentType then + return true + end + + objectSize += 1 + end + + -- If there are more keys than the length of the array, it's an array with + -- non-sequential keys. + if objectSize > #object and keyType == "number" then + return true + end + + return false +end + +local function validate(value: any, key: any, log: (string) -> ()) + local typeOfKey = type(key) + local typeOfValue = type(value) + + if not SAFE_KEYS[typeOfKey] then + return log(`Invalid key type '{typeOfKey}' at key '{key}'`) + elseif not SAFE_VALUES[typeOfValue] then + return log(`Invalid value type '{typeOfValue}' at key '{key}'`) + elseif typeOfValue == "table" then + if getmetatable(value) ~= nil then + return log(`Cannot sync tables with metatables! Got {value} at key '{key}'`) + elseif isAtom(value) then + return log(`Cannot sync nested atoms! Got atom '{value.debugLabel}' at key '{key}'`) + elseif isUnsafeTable(value) then + return log( + `Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n` + .. "This can be for the following reasons:\n" + .. "1. The object is an array with non-sequential keys\n" + .. "2. The object is a dictionary with mixed key types (e.g. string and number)\n\n" + .. "Read more: https://create.roblox.com/docs/scripting/events/remote#argument-limitations" + ) + end + end + + if typeOfValue == "number" then + if value ~= value then + return log(`Cannot sync NaN at key '{key}'`) + elseif value == math.huge or value == -math.huge then + return log(`Cannot sync infinity at key '{key}'`) + end + elseif typeOfKey == "number" then + if key == math.huge or key == -math.huge then + return log("Cannot sync infinity as key") + elseif math.floor(key) ~= key then + return log("Cannot sync non-integer number as key") + end + end +end + +return validate diff --git a/src/test.luau b/src/test.luau new file mode 100644 index 0000000..60c661b --- /dev/null +++ b/src/test.luau @@ -0,0 +1,43 @@ +local Players = game:GetService("Players") + +local Charm = require(script.Parent) +local remotes: any = nil + +local todoCount = 3 + +local todos = Charm.atom({ + { id = 1, text = "Learn about Charm" }, + { id = 2, text = "Build an app" }, + { id = 3, text = "Profit" }, +}) + +local function addTodo(text) + todoCount += 1 + todos(function(state) + local newState = table.clone(state) + table.insert(newState, { id = todoCount, text = text }) + return newState + end) +end + +local client = Charm.sync.client({ + atoms = { todos = todos }, +}) + +remotes.init:fire() + +remotes.sync:connect(function(payload) + client:sync(payload) +end) + +local server = Charm.sync.server({ + atoms = { todos = todos }, +}) + +server:onSync(function(player, payload) + print(player.Name, "synced", payload) +end) + +Players.PlayerAdded:Connect(function(player) + server:hydrate(player) +end)