diff --git a/packages/charm-sync/src/__tests__/e2e.spec.luau b/packages/charm-sync/src/__tests__/e2e.spec.luau new file mode 100644 index 0000000..e386e85 --- /dev/null +++ b/packages/charm-sync/src/__tests__/e2e.spec.luau @@ -0,0 +1,96 @@ +local Charm = require("../../Charm") +local client = require("../client") +local config = require("../config") +local server = require("../server") +local suite = require("../../../../test/suite") + +local afterEach = suite.afterEach +local test = suite.test + +afterEach(function() + server.reset() + client.clearAtoms() + config.preserveHistory = false +end) + +test("should replicate server state to client", function() + local target = {} + local serverA = Charm.atom({ count = 0 }) + local serverB = Charm.atom({ count = 0 }) + local clientA = Charm.atom({ count = -1 }) + local clientB = Charm.atom({ count = -1 }) + + client.registerAtoms({ a = clientA, b = clientB }) + server.syncAtomsToClient(target, { a = serverA, b = serverB }) + server.startSync(function(syncTarget, payloads) + if syncTarget == target then + client.applyPayloads(payloads) + end + end) + + server.flush() + assert(clientA().count == 0, "Client atom A should be initialized to 0") + assert(clientB().count == 0, "Client atom B should be initialized to 0") + + serverA({ count = 1 }) + serverB({ count = 1 }) + server.flush() + assert(clientA().count == 1, "Client atom A should be updated to 1") + assert(clientB().count == 1, "Client atom B should be updated to 1") + + local prevClientB = clientB() + serverA({ count = 2 }) + server.flush() + assert(clientA().count == 2, "Client atom A should be updated to 2") + assert(clientB() == prevClientB, "Client atom B should not be updated") +end) + +test("should replicate historical server state to client", function() + local target = {} + local serverA = Charm.atom({ count = 0 }) + local serverB = Charm.atom({ count = 0 }) + local clientA = Charm.atom({ count = -1 }) + local clientB = Charm.atom({ count = -1 }) + + config.preserveHistory = true + + client.registerAtoms({ a = clientA, b = clientB }) + server.syncAtomsToClient(target, { a = serverA, b = serverB }) + server.startSync(function(syncTarget, payloads) + if syncTarget == target then + client.applyPayloads(payloads) + end + end) + + local expectedA = { -1, 0, 1, 2, 3, 4 } + local expectedB = { -1, 0, 1, 2 } + + Charm.effect(function() + local expected = table.remove(expectedA, 1) + assert(expected == clientA().count, `Client atom A should be {expected}`) + end) + + Charm.effect(function() + local expected = table.remove(expectedB, 1) + assert(expected == clientB().count, `Client atom B should be {expected}`) + end) + + server.flush() + + serverA({ count = 1 }) + serverA({ count = 2 }) + serverB({ count = 1 }) + serverB({ count = 2 }) + server.flush() + assert(clientA().count == 2, "Client atom A should be updated to 2") + assert(clientB().count == 2, "Client atom B should be updated to 2") + + local prevClientB = clientB() + serverA({ count = 3 }) + serverA({ count = 4 }) + server.flush() + assert(clientA().count == 4, "Client atom A should be updated to 4") + assert(clientB() == prevClientB, "Client atom B should not be updated") +end) + +return {} diff --git a/packages/charm-sync/src/__tests__/server.spec.luau b/packages/charm-sync/src/__tests__/server.spec.luau new file mode 100644 index 0000000..744c75e --- /dev/null +++ b/packages/charm-sync/src/__tests__/server.spec.luau @@ -0,0 +1,306 @@ +local Charm = require("../../Charm") +local config = require("../config") +local patch = require("../patch") +local server = require("../server") +local suite = require("../../../../test/suite") + +local afterEach = suite.afterEach +local beforeEach = suite.beforeEach +local describe = suite.describe +local test = suite.test + +afterEach(function() + server.reset() +end) + +test("should replicate state changes", function() + local client = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local c = Charm.atom(0) + local target, payloads + + server.syncAtomsToClient(client, { a = a, b = b, c = c }) + server.startSync(function(...) + target, payloads = ... + end) + + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == a(), "Expected init payload to contain atom 'a'") + assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'") + assert(payloads[1].data.c == c(), "Expected init payload to contain atom 'c'") + + a(1) + b(2) + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a == a(), "Expected patch payload to contain atom 'a'") + assert(payloads[1].data.b == b(), "Expected patch payload to contain atom 'b'") +end) + +test("should replicate partial state patches", function() + local client = {} + local a = Charm.atom({ x = 0, y = 0 }) + local b = Charm.atom(0 :: number?) + local target, payloads + + server.syncAtomsToClient(client, { a = a, b = b }) + server.startSync(function(...) + target, payloads = ... + end) + + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a.x == a().x, "Expected init payload to contain atom 'a.x'") + assert(payloads[1].data.a.y == a().y, "Expected init payload to contain atom 'a.y'") + assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'") + + a({ x = 1, y = 0 }) + b(nil) + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a.x == 1, "Expected patch payload to contain atom 'a.x'") + assert(payloads[1].data.a.y == nil, "Expected patch payload to not contain atom 'a.y'") + assert(patch.isNone(payloads[1].data.b), "Expected patch payload to contain atom 'b' as None") +end) + +test("should init new clients", function() + local clientA = {} + local clientB = {} + local a = Charm.atom(0) + local payloadsA, payloadsB + + server.syncAtomsToClient(clientA, { a = a }) + server.startSync(function(target, payloads) + if target == clientA then + payloadsA = payloads + elseif target == clientB then + payloadsB = payloads + end + end) + + a(1) + server.flush() + assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") + assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A") + + server.syncAtomsToClient(clientB, { a = a }) + a(2) + server.flush() + assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A") + assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A") + assert(payloadsB[1].type == "init", "Expected first payload to be init for client B") + assert(payloadsB[1].data.a == a(), "Expected init payload to contain atom 'a' for client B") +end) + +test("should init new atoms", function() + local client = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local target, payloads + + server.syncAtomsToClient(client, { a = a }) + server.startSync(function(...) + target, payloads = ... + end) + + server.flush() + server.syncAtomsToClient(client, { b = b }) + a(1) + b(1) + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.b == b(), "Expected init payload to contain atom 'b'") + assert(payloads[2].type == "patch", "Expected second payload to be patch") + assert(payloads[2].data.a == a(), "Expected patch payload to contain atom 'a'") + assert(payloads[2].data.b == nil, "Expected patch payload to not contain atom 'b'") +end) + +test("should only replicate to synced clients", function() + local clientA = {} + local clientB = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local payloadsA, payloadsB + + server.syncAtomsToClient(clientA, { a = a }) + server.syncAtomsToClient(clientB, { b = b }) + server.startSync(function(target, payloads) + if target == clientA then + payloadsA = payloads + elseif target == clientB then + payloadsB = payloads + end + end) + + server.flush() + assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") + assert(payloadsA[1].data.a == a(), "Expected init payload to contain atom 'a' for client A") + assert(payloadsA[1].data.b == nil, "Expected init payload to not contain atom 'b' for client A") + assert(payloadsB[1].type == "init", "Expected first payload to be init for client B") + assert(payloadsB[1].data.b == b(), "Expected init payload to contain atom 'b' for client B") + assert(payloadsB[1].data.a == nil, "Expected init payload to not contain atom 'a' for client B") + + payloadsA = {} + payloadsB = {} + a(1) + server.flush() + assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A") + assert(payloadsA[1].data.a == a(), "Expected patch payload to contain atom 'a' for client A") + assert(payloadsA[1].data.b == nil, "Expected patch payload to not contain atom 'b' for client A") + assert(#payloadsB == 0, "Expected no payloads for client B") + + payloadsA = {} + payloadsB = {} + b(1) + server.flush() + assert(#payloadsA == 0, "Expected no payloads for client A") + assert(payloadsB[1].type == "patch", "Expected first payload to be patch for client B") + assert(payloadsB[1].data.b == b(), "Expected patch payload to contain atom 'b' for client B") + assert(payloadsB[1].data.a == nil, "Expected patch payload to not contain atom 'a' for client B") +end) + +test("should validate state", function() + local client = {} + local a = Charm.atom({}) + + server.syncAtomsToClient(client, { a = a }) + server.startSync(function() end) + + a({ [0.5] = true }) + assert(not pcall(server.flush), "Expected server.flush to fail") + + a({ [0.5] = true, valid = true }) + server.flush() +end) + +test("should serialize state", function() + local client = {} + local a = Charm.atom({ 1, 2, 3, 4, 5 }) + local payloads + + server.syncAtomsToClient(client, { a = a }) + server.startSync(function(_, p) + payloads = p + end) + server.flush() + + a({ 1, 2, 0, 4 }) + server.flush() + assert(payloads[1].data.a["3"] == 0, "Expected index 3 to be serialized as string key") + assert(patch.isNone(payloads[1].data.a["5"]), "Expected index 5 to be serialized as string key") +end) + +describe("historical", function() + beforeEach(function() + config.preserveHistory = true + end) + + afterEach(function() + config.preserveHistory = false + end) + + test("should replicate granular state changes", function() + local client = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local c = Charm.atom(0) + local target, payloads + + server.syncAtomsToClient(client, { a = a, b = b, c = c }) + server.startSync(function(...) + target, payloads = ... + end) + + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") + assert(payloads[1].data.b == 0, "Expected init payload to contain atom 'b'") + assert(payloads[1].data.c == 0, "Expected init payload to contain atom 'c'") + + a(1) + a(2) + b(2) + server.flush() + assert(target == client, "Expected target to be the client") + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'") + assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'") + assert(payloads[3].data.b == 2, "Expected patch payload to contain atom 'b'") + end) + + test("should init new clients", function() + local clientA = {} + local clientB = {} + local a = Charm.atom(0) + local payloadsA, payloadsB + + server.syncAtomsToClient(clientA, { a = a }) + server.startSync(function(target, payloads) + if target == clientA then + payloadsA = payloads + elseif target == clientB then + payloadsB = payloads + end + end) + + a(1) + a(2) + server.flush() + assert(payloadsA[1].type == "init", "Expected first payload to be init for client A") + assert(payloadsA[1].data.a == 1, "Expected init payload to contain atom 'a' for client A") + assert(payloadsA[2].type == "patch", "Expected second payload to be patch for client A") + assert(payloadsA[2].data.a == 2, "Expected second patch payload to contain atom 'a' with value 1 for client A") + + server.syncAtomsToClient(clientB, { a = a }) + a(3) + a(4) + server.flush() + assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A") + assert(payloadsA[1].data.a == 3, "Expected patch payload to contain atom 'a' for client A") + assert(payloadsA[2].data.a == 4, "Expected patch payload to contain atom 'a' for client A") + assert(payloadsB[1].type == "init", "Expected first payload to be init for client B") + assert(payloadsB[1].data.a == 3, "Expected init payload to contain atom 'a' for client B") + assert(payloadsB[2].type == "patch", "Expected second payload to be patch for client B") + assert(payloadsB[2].data.a == 4, "Expected second patch payload to contain atom 'a' with value 3 for client B") + end) + + test("should init new atoms", function() + local client = {} + local a = Charm.atom(0) + local b = Charm.atom(0) + local payloads + + server.syncAtomsToClient(client, { a = a }) + server.startSync(function(_, ...) + payloads = ... + end) + + server.flush() + assert(payloads[1].type == "init", "Expected first payload to be init") + assert(payloads[1].data.a == 0, "Expected init payload to contain atom 'a'") + + server.syncAtomsToClient(client, { b = b }) + a(1) + a(2) + b(1) + b(2) + server.flush() + assert(payloads[1].type == "patch", "Expected first payload to be patch") + assert(payloads[1].data.a == 1, "Expected patch payload to contain atom 'a'") + assert(payloads[2].data.a == 2, "Expected patch payload to contain atom 'a'") + assert(payloads[3].type == "init", "Expected third payload to be init") + assert(payloads[3].data.b == 1, "Expected init payload to contain atom 'b'") + assert(payloads[4].type == "patch", "Expected fourth payload to be patch") + assert(payloads[4].data.b == 2, "Expected patch payload to contain atom 'b'") + end) +end) + +return {} diff --git a/packages/charm-sync/src/__tests__/spec.luau b/packages/charm-sync/src/__tests__/spec.luau index 2fde10b..2b6363e 100644 --- a/packages/charm-sync/src/__tests__/spec.luau +++ b/packages/charm-sync/src/__tests__/spec.luau @@ -4,6 +4,10 @@ suite.describe("client", function() require("./client.spec") end) +suite.describe("e2e", function() + require("./e2e.spec") +end) + suite.describe("flatten", function() require("./flatten.spec") end) @@ -12,6 +16,10 @@ suite.describe("patch", function() require("./patch.spec") end) +suite.describe("server", function() + require("./server.spec") +end) + suite.describe("validate", function() require("./validate.spec") end) diff --git a/packages/charm-sync/src/patch.luau b/packages/charm-sync/src/patch.luau index 859cddc..617efad 100644 --- a/packages/charm-sync/src/patch.luau +++ b/packages/charm-sync/src/patch.luau @@ -54,19 +54,19 @@ end --- @param prevState The previous state. --- @param nextState The next state. --- @returns A patch that can be applied to the previous state. -local function diff(prevState: { [any]: any }, nextState: { [any]: any }) - local patches = table.clone(nextState) +local function diff(prevState: any, nextState: any): any + if prevState == nextState then + return nil + elseif nextState == nil then + return None + elseif type(prevState) ~= "table" or type(nextState) ~= "table" then + return nextState + end + + local patches: { [any]: any } = table.clone(nextState) for key, previous in prevState do - local current = nextState[key] - - if previous == current then - patches[key] = nil - elseif current == nil then - patches[key] = None - elseif type(previous) == "table" and type(current) == "table" then - patches[key] = diff(previous, current) - end + patches[key] = diff(previous, nextState[key]) end if config.autoSerialize then @@ -87,20 +87,20 @@ end --- Applies a patch to a state and returns the next state. --- ---- @param state The current state. ---- @param patches The patches to apply. +--- @param prevState The current state. +--- @param statePatch The patches to apply. --- @returns The next state. -local function apply(state: any, patches: any): any - if isNone(patches) then +local function apply(prevState: any, statePatch: any): any + if isNone(statePatch) then return nil - elseif type(state) ~= "table" or type(patches) ~= "table" then - return patches + elseif type(prevState) ~= "table" or type(statePatch) ~= "table" then + return statePatch end - local nextState = table.clone(state) - local stateIsArray = state[1] ~= nil + local nextState = table.clone(prevState) + local stateIsArray = prevState[1] ~= nil - for key, patch in patches do + for key, value in statePatch do -- Diff-checking an array produces a sparse array, which will not be -- preserved when converted to JSON. To prevent this, we turn string -- keys back into numeric keys. @@ -108,7 +108,7 @@ local function apply(state: any, patches: any): any key = tonumber(key) or key end - nextState[key] = apply(nextState[key], patch) + nextState[key] = apply(nextState[key], value) end return nextState diff --git a/packages/charm-sync/src/server.luau b/packages/charm-sync/src/server.luau index b645e8e..55f94b6 100644 --- a/packages/charm-sync/src/server.luau +++ b/packages/charm-sync/src/server.luau @@ -235,47 +235,73 @@ local function unsyncAtomsFromClient(client: any, ...: string) end end -local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()) +local function onSyncCallback(_client: any, _payloads: { SyncPayload }) + warn("[Charm] Override the onSync function by passing a custom function to startSync") +end + +local function flush() + local patches = if config.preserveHistory then createHistoricalPatches() else createPatch() + + -- While payload objects are technically mutable as they are unique to + -- each client, the data inside them is not safe to mutate. + if Charm.flags.frozen then + Charm.deepFreeze(patches) + end + + for client in clientRegister do + local payloads: { SyncPayload } = if config.preserveHistory + then createHistoricalPayloadsForClient(client, patches) + else createPayloadsForClient(client, patches) + + if payloads[1] then + if task then + task.spawn(onSyncCallback, client, payloads) + else + onSyncCallback(client, payloads) + end + end + end + + unregisterUnusedAtoms() + table.clear(globalUpdates) +end + +local function stopSync() + if connection then + connection:Disconnect() + connection = nil + end +end + +local function reset() + table.clear(atomRegister) + table.clear(clientRegister) + table.clear(globalUpdates) + stopSync() +end + +local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ()): () -> () if connection then connection:Disconnect() end - local elapsedTime = 0 + onSyncCallback = onSync - local function sync() - local patches = if config.preserveHistory then createHistoricalPatches() else createPatch() + if game then + local elapsedTime = 0 - -- While payload objects are technically mutable as they are unique to - -- each client, the data inside them is not safe to mutate. - if Charm.flags.frozen then - Charm.deepFreeze(patches) - end - - for client in clientRegister do - local payloads: { SyncPayload } = if config.preserveHistory - then createHistoricalPayloadsForClient(client, patches) - else createPayloadsForClient(client, patches) - - if payloads[1] then - task.spawn(onSync, client, payloads) + connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime) + elapsedTime += deltaTime + if elapsedTime >= config.interval then + elapsedTime %= config.interval + debug.profilebegin("CharmSync") + flush() + debug.profileend() end - end - - unregisterUnusedAtoms() - table.clear(globalUpdates) + end) end - connection = game:GetService("RunService").Heartbeat:Connect(function(deltaTime: number) - elapsedTime += deltaTime - - if elapsedTime >= config.interval then - elapsedTime %= config.interval - - debug.profilebegin("CharmSync") - sync() - debug.profileend() - end - end) + return stopSync end return { @@ -283,4 +309,7 @@ return { unsyncClient = unsyncClient, unsyncAtomsFromClient = unsyncAtomsFromClient, startSync = startSync, + stopSync = stopSync, + flush = flush, + reset = reset, }