refactor: start charm-sync rewrite

This commit is contained in:
littensy 2025-09-29 18:11:39 -07:00
parent 4a07297a13
commit 079abaacc1
18 changed files with 468 additions and 560 deletions

View file

@ -1,3 +1,7 @@
if not script then
return require("../charm/src")
end
local Charm = require(script.Parent.Parent.charm.src)
export type Atom<T = any> = Charm.Atom<T>

View file

@ -1,19 +1,22 @@
local Charm = require("../../Charm")
local CharmSync = require("../")
local client = require("../client")
local suite = require("../../../../test/suite")
local atom = Charm.atom
local subscribe = Charm.subscribe
local client = CharmSync.client
local test = suite.test
local afterEach = suite.afterEach
afterEach(function()
client.clearAtoms()
end)
test("should sync state payload", function()
local a = atom(1)
local client = client({ atoms = { a = a } })
client:sync({
type = "init",
data = { a = 2 },
client.registerAtoms({ a = a })
client.applyPayloads({
{ type = "init", data = { a = 2 } },
})
assert(a() == 2, "should set a to the initial value")
@ -22,18 +25,16 @@ end)
test("should sync state patch", function()
local a = atom({ b = 1, c = 2 })
local previous = a()
local client = client({ atoms = { a = a } })
client:sync({
type = "init",
data = { a = a() },
client.registerAtoms({ a = a })
client.applyPayloads({
{ type = "init", data = { a = a() } },
})
assert(a() == previous, "should set a to the initial value")
client:sync({
type = "patch",
data = { a = { b = 3 } },
client.applyPayloads({
{ type = "patch", data = { a = { b = 3 } } },
})
assert(a() ~= previous, "should apply patch")
@ -43,19 +44,17 @@ end)
test("should fix sparse array patches", function()
local a = atom({ 1 })
local client = client({ atoms = { a = a } })
client:sync({
type = "init",
data = { a = a() },
client.registerAtoms({ a = a })
client.applyPayloads({
{ type = "init", data = { a = a() } },
})
-- This patch would be the result of a diff between { 1 } and { 1, 2 } that
-- was JSON-serialized and deserialized. The client should be able to catch
-- this and convert the string key back to a number.
client:sync({
type = "patch",
data = { a = { ["2"] = 2 } },
client.applyPayloads({
{ type = "patch", data = { a = { ["2"] = 2 } } },
})
assert(a()["2" :: any] == nil, "should convert serialized arrays to use numbers")
@ -64,24 +63,21 @@ end)
test("should apply multiple patches", function()
local a = atom(1)
local client = client({ atoms = { a = a } })
local calls = {}
client.registerAtoms({ a = a })
subscribe(a, function(value)
table.insert(calls, value)
end)
client:sync({
type = "init",
data = { a = a() },
client.applyPayloads({
{ type = "init", data = { a = a() } },
})
client:sync({
type = "patch",
data = { a = 2 },
}, {
type = "patch",
data = { a = 3 },
client.applyPayloads({
{ type = "patch", data = { a = 2 } },
{ type = "patch", data = { a = 3 } },
})
assert(a() == 3, "should apply the last patch")
@ -91,9 +87,10 @@ end)
test("should batch patches", function()
local a = atom(0)
local b = atom(0)
local client = client({ atoms = { a = a, b = b } })
local states = {}
client.registerAtoms({ a = a, b = b })
subscribe(a, function()
states.b = b()
end)
@ -102,14 +99,12 @@ test("should batch patches", function()
states.a = a()
end)
client:sync({
type = "init",
data = { a = a(), b = b() },
client.applyPayloads({
{ type = "init", data = { a = a(), b = b() } },
})
client:sync({
type = "patch",
data = { a = 1, b = 2 },
client.applyPayloads({
{ type = "patch", data = { a = 1, b = 2 } },
})
assert(a() == 1, "should set a to the new value")

View file

@ -1,9 +1,8 @@
local Charm = require("../../Charm")
local CharmSync = require("../")
local flatten = require("../flatten")
local suite = require("../../../../test/suite")
local atom = Charm.atom
local flatten = CharmSync.flatten
local test = suite.test
test("should flatten atoms", function()

View file

@ -1,14 +1,32 @@
local HttpService = game:GetService("HttpService")
local CharmSync = require("../")
local config = require("../config")
local patch = require("../patch")
local suite = require("../../../../test/suite")
local patch = CharmSync.patch
local test = suite.test
local afterEach = suite.afterEach
local function serdes(value: { [any]: any })
return HttpService:JSONDecode(HttpService:JSONEncode(value))
local function mockArraySerdes(object: { [any]: any }): { [any]: any }
if object[1] then
local copy = {}
for index = 1, #object do
copy[index] = object[index]
end
return copy
elseif table.maxn(object) ~= #object then
local copy = {}
for key, value in object do
copy[tostring(key)] = value
end
return copy
else
return object
end
end
afterEach(function()
config.autoSerialize = true
config.validatePatches = true
end)
test("returns a patch", function()
local prevState = { foo = "bar", baz = "qux" }
@ -60,7 +78,7 @@ test("preserves array density after serdes", function()
local prevState = { "a", "b", "c", "d", "e" }
local nextState = { "A", "b", "c", "D" }
local patches = serdes(patch.diff(prevState, nextState))
local patches = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
for index in next, prevState do
@ -72,27 +90,11 @@ test("preserves array density after serdes", function()
end
end)
test("preserves large gaps between keys", function()
local prevState = table.create(10000, "foo")
local nextState = table.move(prevState, 2, 9998, 2, { [1] = "bar", [9999] = "bar" })
local start = os.clock()
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
assert(os.clock() - start < 10e-3, `Performance is not acceptable ({(os.clock() - start) * 1e3}ms)`)
for index in next, prevState do
assert(applied[index] == nextState[index], `expected {applied[index]} to be {nextState[index]}`)
end
end)
test("cannot preserve sparse arrays", function()
local prevState = {}
local nextState = { [2] = "foo" }
local patches = serdes(patch.diff(prevState, nextState))
local patches = mockArraySerdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
-- Not enough information is available to determine whether 'patches'
@ -102,24 +104,30 @@ test("cannot preserve sparse arrays", function()
end)
test("optionally fixes sparse arrays", function()
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, true) do
config.autoSerialize = true
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
assert(type(index) == "string", `expected {type(index)} {index} to be string`)
end
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do
config.autoSerialize = false
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
assert(type(index) == "number", `expected {type(index)} {index} to be number`)
end
end)
test("optionally validates state", function()
local success = pcall(function()
patch.diff({}, { [0.1] = true }, true)
config.validatePatches = true
patch.diff({}, { [0.1] = true })
end)
assert(not success, "should not allow non-integer keys")
success = pcall(function()
patch.diff({}, { [0.1] = true }, false)
config.validatePatches = false
patch.diff({}, { [0.1] = true })
end)
assert(success, "should skip validation if not requested")

View file

@ -1,99 +0,0 @@
local Charm = require("../../Charm")
local CharmSync = require("../")
local suite = require("../../../../test/suite")
type SyncPayload = CharmSync.SyncPayload
local atom = Charm.atom
local server = CharmSync.server
local patch = CharmSync.patch
local test = suite.test
local target: Player = {} :: any
test("sends state to new players", function()
local a = atom(1)
local server = server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: SyncPayload
local disconnect = server:connect(function(...)
player, payload = ...
end)
server:hydrate(target)
assert(player == target, "should send to the target player")
assert(payload.type == "init", "should be an init payload")
assert(payload.data.a == 1, "should send the initial state")
disconnect()
end)
test("sends state patches", function()
local a = atom({ b = 1, c = 2 } :: { [string]: number? })
local server = server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: SyncPayload
local disconnect = server:connect(function(...)
player, payload = ...
end)
a({ b = 1, c = 5 })
server:_sendPatch(target)
assert(player == target, "should send to the target player")
assert(payload.type == "patch", "should be a patch payload")
assert(payload.data.a.b == nil, "should not send unchanged values")
assert(payload.data.a.c == 5, "should send changed values")
a({ b = 1, d = 1 })
server:_sendPatch(target)
assert(payload.data.a.b == nil, "should not send unchanged values")
assert(patch.isNone(payload.data.a.c) == true, "should send None for removed values")
assert(payload.data.a.d == 1, "should send new values")
disconnect()
end)
test("preserves history", function()
local a = atom(1)
local b = atom(1)
local payloads: { SyncPayload }
local server = server({
atoms = { a = a, b = b },
interval = -1,
preserveHistory = true,
})
local disconnect = server:connect(function(_, ...)
payloads = { ... }
end)
a(2)
b(2)
server:_sendPatch(target)
assert(#payloads == 1, "should only have one payload")
assert(payloads[1].type == "patch", "should be a patch payload")
assert(payloads[1].data.a == 2, "should send the new value of a")
assert(payloads[1].data.b == 2, "should send the new value of b")
a(3)
a(4)
b(3)
server:_sendPatch(target)
assert(#payloads == 2, "should have two payloads")
assert(payloads[1].type == "patch", "1 should be a patch payload")
assert(payloads[1].data.a == 3, "1 should send the new value of a")
assert(payloads[1].data.b == nil, "1 should not send unchanged values")
assert(payloads[2].type == "patch", "2 should be a patch payload")
assert(payloads[2].data.a == 4, "2 should send the new value of a")
assert(payloads[2].data.b == 3, "2 should send the new value of b")
disconnect()
end)
return {}

View file

@ -4,10 +4,6 @@ suite.describe("client", function()
require("./client.spec")
end)
suite.describe("server", function()
require("./server.spec")
end)
suite.describe("flatten", function()
require("./flatten.spec")
end)

View file

@ -1,9 +1,8 @@
local Charm = require("../../Charm")
local CharmSync = require("../")
local suite = require("../../../../test/suite")
local validate = require("../validate")
local atom = Charm.atom
local validate = CharmSync.validate
local test = suite.test
test("validates inputs", function()

View file

@ -2,75 +2,72 @@ local Charm = require("../Charm")
local patch = require("./patch")
local types = require("./types")
type AtomMap = types.AtomMap
type SyncPayload = types.SyncPayload
type ClientOptions = {
--- The atoms to synchronize with the server.
atoms: AtomMap,
--- Whether to ignore patches sent before the client has been hydrated.
--- Default is `true`.
ignoreUnhydrated: boolean?,
}
local atomRegister: { [string]: Charm.Atom } = {}
type ClientSyncer = {
--- Applies a patch or initializes the state of the atoms with the given
--- payload from the server.
---
--- @param ...payloads The patches or hydration payloads to apply.
sync: (self: ClientSyncer, ...SyncPayload) -> (),
}
--- @client
local function registerAtoms(atoms: { [string]: Charm.Atom })
for key, atom in atoms do
if atomRegister[key] and atomRegister[key] ~= atom then
warn("[Charm] Attempted to sync different atoms with the same key:", key)
continue
end
--- Creates a `ClientSyncer` object that receives patches from the server and
--- applies them to the local state.
---
--- @param options The atoms to synchronize with the server.
--- @return A `ClientSyncer` object.
local function client(options: ClientOptions): ClientSyncer
local atoms = options.atoms
local ignoreUnhydrated = options.ignoreUnhydrated ~= false
atomRegister[key] = atom
end
end
local self = {} :: ClientSyncer
local hydrated = false
--- @client
local function clearAtoms()
table.clear(atomRegister)
end
local function hydrate(state: { [string | number]: any })
hydrated = true
--- @client
local function unregisterAtoms(...: string)
for index = 1, select("#", ...) do
local key = select(index, ...)
atomRegister[key] = nil
end
end
for key, value in state do
local atom = atoms[key]
local function applyInitPayload(data: { [string]: any })
for key, value in data do
local atom = atomRegister[key]
if atom then
atom(value)
end
end
end
local function apply(data: { [string | number]: any })
local function applyPatchPayload(data: { [string | number]: any })
local target = {}
for key, atom in atoms do
for key, atom in atomRegister do
target[key] = atom()
end
target = patch.apply(target, data)
for key, atom in atoms do
for key, atom in atomRegister do
atom(target[key])
end
end
function self:sync(...)
for index = 1, select("#", ...) do
local payload: SyncPayload = select(index, ...)
Charm.batched(function()
--- @client
local function applyPayloads(payloads: { SyncPayload })
for _, payload in payloads do
if payload.type == "init" then
hydrate(payload.data)
elseif not ignoreUnhydrated or hydrated then
apply(payload.data)
Charm.batched(applyInitPayload, payload.data)
elseif payload.type == "patch" then
Charm.batched(applyPatchPayload, payload.data)
end
end)
end
end
return self
end
return client
return {
registerAtoms = registerAtoms,
clearAtoms = clearAtoms,
unregisterAtoms = unregisterAtoms,
applyPayloads = applyPayloads,
}

View file

@ -0,0 +1,20 @@
return {
--- The interval at which to send patches to the client, in seconds.
--- Defaults to `0` (patches are sent up to once per frame). Set to a
--- negative value to disable automatic syncing.
interval = 0,
--- Whether the history of state changes since the client's last update
--- should be preserved. This is useful for values that change multiple
--- times per frame, where each individual change is important. Defaults
--- to `false`.
preserveHistory = false,
--- When `true`, Charm will apply validation and serialize unsafe arrays
--- to address remote event argument limitations. Defaults to `true`.
--- This option should be disabled if your network library uses a custom
--- serialization method (i.e. Zap, ByteNet) to prevent interference.
autoSerialize = true,
--- When `true`, patches will be validated before being sent to clients to
--- enforce remote event limitations. Only checked in strict mode and if
--- `autoSerialize` is enabled. Defaults to `true`.
validatePatches = true,
}

View file

@ -1,13 +1,9 @@
local types = require("./types")
type AtomMap = types.AtomMap
type NestedAtomMap = {
[string]: NestedAtomMap | () -> (),
[string]: NestedAtomMap | () -> any,
}
local function flatten(atoms: NestedAtomMap): AtomMap
local result: AtomMap = {}
local function flatten(atoms: NestedAtomMap): { [string]: () -> any }
local result: { [string]: () -> any } = {}
local function visit(node: NestedAtomMap, path: string)
for key, value in node do

View file

@ -1,17 +1,18 @@
local client = require("@self/client")
local config = require("@self/config")
local flatten = require("@self/flatten")
local patch = require("@self/patch")
local server = require("@self/server")
local types = require("@self/types")
local validate = require("@self/validate")
export type None = types.None
export type SyncPayload = types.SyncPayload
return {
config = config,
client = client,
server = server,
flatten = flatten,
validate = validate,
patch = patch,
isNone = patch.isNone,
flatten = flatten,
}

View file

@ -1,26 +0,0 @@
--- Schedules a function to be called at a fixed interval of `seconds`.
---
--- @param callback The function to call.
--- @param seconds The interval in seconds.
--- @return A function that cancels the interval.
local function interval(callback: () -> (), seconds: number): () -> ()
if seconds < 0 then
return function() end
end
local clock = 0
local connection = game:GetService("RunService").Heartbeat:Connect(function(delta)
clock += delta
if clock >= seconds then
clock = 0
callback()
end
end)
return function()
connection:Disconnect()
end
end
return interval

View file

@ -1,16 +1,28 @@
local Charm = require("../Charm")
local config = require("./config")
local types = require("./types")
local validate = require("./validate")
local flags = Charm.flags
export type None = types.None
--- A special symbol that denotes the absence of a value. Used to represent
--- deleted values in patches.
local None = { __none = "__none" }
local None: None = { __none = "__none" }
--- Checks if a value is the special `None` symbol.
local function isNone(value: any): boolean
return type(value) == "table" and value.__none == "__none"
end
--- Converts `nil` values to the special `None` symbol. This is useful when
--- preparing data for serialization, since `nil` can represent both the absence
--- of a value and the removal of a value in patches.
local function nilToNone<T>(value: T?): T | None
return if value == nil then None else value
end
--- JSON serialization can drop the last values of a sparse array. For example,
--- `{ [1] = 1, [3] = 3 }` becomes `[1]` in some cases. Ambiguity in the
--- serialization of sparse arrays can be resolved by converting them to
@ -29,7 +41,7 @@ local function stringifySparseArray(object: { any }): { [string | number]: any }
local dictionary = {}
for index, value in next, object do
for index, value in object do
dictionary[tostring(index)] = value
end
@ -41,14 +53,11 @@ end
---
--- @param prevState The previous state.
--- @param nextState The next state.
--- @param serialize Whether to convert sparse arrays into dictionaries. Defaults to `true`.
--- @returns A patch that can be applied to the previous state.
local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serialize: boolean?)
serialize = serialize ~= false
local function diff(prevState: { [any]: any }, nextState: { [any]: any })
local patches = table.clone(nextState)
for key, previous in next, prevState do
for key, previous in prevState do
local current = nextState[key]
if previous == current then
@ -56,20 +65,22 @@ local function diff(prevState: { [any]: any }, nextState: { [any]: any }, serial
elseif current == nil then
patches[key] = None
elseif type(previous) == "table" and type(current) == "table" then
patches[key] = diff(previous, current, serialize)
patches[key] = diff(previous, current)
end
end
if config.autoSerialize then
-- Coerce sparse array patches into dictionaries
if serialize and (prevState[1] ~= nil or nextState[1] ~= nil) then
if prevState[1] ~= nil or nextState[1] ~= nil then
patches = stringifySparseArray(patches)
end
if serialize and flags.strict then
for key, value in next, patches do
if flags.strict and config.validatePatches then
for key, value in patches do
validate(value, key)
end
end
end
return patches
end
@ -80,7 +91,7 @@ end
--- @param patches The patches to apply.
--- @returns The next state.
local function apply(state: any, patches: any): any
if type(patches) == "table" and patches.__none == "__none" then
if isNone(patches) then
return nil
elseif type(state) ~= "table" or type(patches) ~= "table" then
return patches
@ -89,7 +100,7 @@ local function apply(state: any, patches: any): any
local nextState = table.clone(state)
local stateIsArray = state[1] ~= nil
for key, patch in next, patches do
for key, patch in patches 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.
@ -105,6 +116,7 @@ end
return {
isNone = isNone,
nilToNone = nilToNone,
diff = diff,
apply = apply,
}

View file

@ -1,250 +1,265 @@
local Charm = require("../Charm")
local interval = require("./interval")
local config = require("./config")
local patch = require("./patch")
local types = require("./types")
type AtomMap = types.AtomMap
type SyncPayload = types.SyncPayload
type ServerOptions = {
--- The atoms to synchronize with the client.
atoms: AtomMap,
--- The interval at which to send patches to the client, in seconds.
--- Defaults to `0` (patches are sent up to once per frame). Set to a
--- negative value to disable automatic syncing.
interval: number?,
--- Whether the history of state changes since the client's last update
--- should be preserved. This is useful for values that change multiple times
--- per frame, where each individual change is important. Defaults to `false`.
preserveHistory: boolean?,
--- When `true`, Charm will apply validation and serialize unsafe arrays
--- to address remote event argument limitations. Defaults to `true`.
---
--- This option should be disabled if your network library uses a custom
--- serialization method (i.e. Zap, ByteNet) to prevent interference.
autoSerialize: boolean?,
type RegisteredAtom<T = any> = {
atom: () -> T,
value: T,
valueLastSynced: T,
stop: () -> (),
}
type ServerSyncer = {
--- Sets up a subscription to each atom that schedules a patch to be sent to
--- the client whenever the state changes. When a change occurs, the `callback`
--- is called with the player and the payload to send.
---
--- Note that the `payload` object should not be mutated. If you need to
--- modify the payload, apply the changes to a copy of the object.
---
--- @param callback The function to call when the state changes.
--- @return A cleanup function that unsubscribes all listeners.
connect: (self: ServerSyncer, callback: (player: Player, ...SyncPayload) -> ()) -> () -> (),
--- Hydrates the client's state with the server's state. This should be
--- called when a player joins the game and requires the server's state.
---
--- @param player The player to hydrate.
hydrate: (self: ServerSyncer, player: Player) -> (),
--- @deprecated For internal use only.
_sendPatch: (self: ServerSyncer, player: Player) -> (),
type SyncStatus = number
local PENDING_INITIAL_STATE: SyncStatus = 0
local LISTENING_FOR_CHANGES: SyncStatus = 1
local atomRegister: { [string]: RegisteredAtom } = {}
local clientRegister: { [any]: { [string]: SyncStatus } } = {}
local globalUpdates: { { key: string, value: any, previousValue: any } } = {}
local connection: RBXScriptConnection?
local function registerAtom<T>(key: string, atom: () -> T)
if atomRegister[key] then
if atomRegister[key].atom ~= atom then
warn("[Charm] Attempted to sync different atoms with the same key:", key)
end
return
end
local registeredAtom: RegisteredAtom<T> = {
atom = atom,
value = atom(),
valueLastSynced = atom(),
stop = function() end,
}
--- Creates a `ServerSyncer` object that sends patches to the client and
--- hydrates the client's state.
---
--- @param options The atoms to synchronize with the client.
--- @return A `ServerSyncer` object.
local function server(options: ServerOptions): ServerSyncer
local atoms = options.atoms
local autoSerialize = options.autoSerialize ~= false
local preserveHistory = options.preserveHistory
local syncRate = options.interval or 0
registeredAtom.stop = Charm.effect(function()
local value = atom()
local self = {} :: ServerSyncer
local sync: (player: Player, payload: SyncPayload) -> ()
local function createSnapshot()
local snapshot = {}
for key, atom in atoms do
snapshot[key] = atom()
if config.preserveHistory and registeredAtom.value ~= value then
table.insert(globalUpdates, {
key = key,
value = value,
previousValue = registeredAtom.value,
})
end
return snapshot
registeredAtom.value = value
end)
atomRegister[key] = registeredAtom
end
-- Send the initial state to a player when they join the server.
function self:hydrate(player)
assert(sync, "connect() must be called before hydrate()")
local function unregisterAtom(key: string)
local registeredAtom = atomRegister[key]
sync(player, {
if registeredAtom then
registeredAtom.stop()
atomRegister[key] = nil
end
end
local function unregisterUnusedAtoms()
local used: { [string]: boolean } = {}
for _, clientAtoms in clientRegister do
for key in clientAtoms do
used[key] = true
end
end
for key in atomRegister do
if not used[key] then
unregisterAtom(key)
end
end
end
local function createHistoricalPatches(): { any }
local patchCount = #globalUpdates
local patches: { any } = table.create(patchCount)
for index, update in globalUpdates do
patches[index] = patch.diff(update.previousValue, update.value)
end
return patches
end
local function createHistoricalPayloadsForClient(client: any, patches: { any }): { SyncPayload }
local syncedAtoms = clientRegister[client]
if not syncedAtoms then
return {}
end
local payloads: { SyncPayload } = {}
for index, update in globalUpdates do
local patchData = patches[index]
if not patchData then
continue
end
local key = update.key
local status = syncedAtoms[key]
if status == LISTENING_FOR_CHANGES then
table.insert(payloads, {
type = "patch",
data = { [key] = patchData },
})
elseif status == PENDING_INITIAL_STATE then
table.insert(payloads, {
type = "init",
data = createSnapshot(),
data = { [key] = update.value },
})
end
-- If the history of state changes should be preserved, keep a list of
-- previous snapshots and diff-check each one from the last.
if preserveHistory then
local snapshots = { createSnapshot() }
local changed = false
function self:connect(callback)
local subscriptions = {}
sync = callback
local function pushSnapshot(key: string | number, current: any, previous: any)
local lastSnapshot = snapshots[#snapshots]
local previousSnapshot = snapshots[#snapshots - 1]
-- Optimize snapshots by updating the most recent snapshot if the
-- previous and current values are the same, since this allows us
-- to group multiple changes into a single snapshot.
if previousSnapshot and previousSnapshot[key] == previous and lastSnapshot[key] == previous then
lastSnapshot[key] = current
else
local nextSnapshot = table.clone(lastSnapshot)
nextSnapshot[key] = current
table.insert(snapshots, nextSnapshot)
syncedAtoms[key] = LISTENING_FOR_CHANGES
end
end
-- Populate the snapshot with the initial state of each atom.
-- Subscribe to each atom and update the state when it changes.
return payloads
end
local function createPatch(): any
local current: { [string]: any } = {}
local previous: { [string]: any } = {}
for key, registeredAtom in atomRegister do
current[key] = registeredAtom.value
previous[key] = registeredAtom.valueLastSynced
registeredAtom.valueLastSynced = registeredAtom.value
end
return patch.diff(previous, current)
end
local function createPayloadsForClient(client: any, patchData: any): { SyncPayload }
local syncedAtoms = clientRegister[client]
local payloads: { SyncPayload } = {}
if not syncedAtoms then
return payloads
end
local shouldSendInitialState = false
local initPayload: { [string]: any } = {}
local patchPayload: { [string]: any } = {}
for key, status in syncedAtoms do
local registeredAtom = atomRegister[key]
if status == LISTENING_FOR_CHANGES then
patchPayload[key] = patchData[key]
elseif status == PENDING_INITIAL_STATE then
initPayload[key] = registeredAtom.value
syncedAtoms[key] = LISTENING_FOR_CHANGES
shouldSendInitialState = true
end
end
if shouldSendInitialState then
table.insert(payloads, { type = "init", data = initPayload })
end
if next(patchPayload) ~= nil then
table.insert(payloads, { type = "patch", data = patchPayload })
end
return payloads
end
--- @server
local function syncAtomsToClient(client: any, atoms: { [string]: () -> any })
if not clientRegister[client] then
clientRegister[client] = {}
end
local syncedAtoms = clientRegister[client]
for key, atom in atoms do
subscriptions[key] = Charm.subscribe(atom, function(current, previous)
pushSnapshot(key, current, previous)
changed = true
registerAtom(key, atom)
if not syncedAtoms[key] then
syncedAtoms[key] = PENDING_INITIAL_STATE
end
end
end
--- @server
local function unsyncClient(client: any)
clientRegister[client] = nil
end
--- @server
local function unsyncAtomsFromClient(client: any, ...: string)
local syncedAtoms = clientRegister[client]
if not syncedAtoms then
return
end
for index = 1, select("#", ...) do
local key = select(index, ...)
syncedAtoms[key] = nil
end
if next(syncedAtoms) == nil then
clientRegister[client] = nil
end
end
--- @server
local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ())
if connection then
connection:Disconnect()
end
local elapsedTime = 0
local function sync()
local patches = if config.preserveHistory then createHistoricalPatches() else createPatch()
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)
end
end
unregisterUnusedAtoms()
table.clear(globalUpdates)
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)
end
local disconnect = interval(function()
if not changed then
return
end
local payloads: { SyncPayload } = {}
local lastSnapshot
for index, snapshot in snapshots do
lastSnapshot = snapshot
if index == 1 then
continue
end
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
})
end
snapshots = { lastSnapshot }
changed = false
for _, player in game:GetService("Players"):GetPlayers() do
callback(player, unpack(payloads))
end
end, syncRate)
return function()
disconnect()
for _, unsubscribe in subscriptions do
unsubscribe()
end
end
end
function self:_sendPatch(player: Player)
assert(sync, "connect() must be called before _sendPatch()")
if not changed then
return
end
local payloads: { SyncPayload } = {}
for index, snapshot in snapshots do
if index == 1 then
continue
end
table.insert(payloads, {
type = "patch",
data = patch.diff(snapshots[index - 1], snapshot, autoSerialize),
})
end
snapshots = { snapshots[#snapshots] }
changed = false
sync(player, unpack(payloads))
end
return self
end
local previousSnapshot = createSnapshot()
local currentSnapshot = table.clone(previousSnapshot)
local changed = false
function self:connect(callback)
local subscriptions = {}
sync = callback
-- Subscribe to each atom and update the state when one changes.
for key, atom in atoms do
subscriptions[key] = Charm.subscribe(atom, function(state)
currentSnapshot[key] = state
changed = true
end)
end
local disconnect = interval(function()
if not changed then
return
end
local payload: SyncPayload = {
type = "patch",
data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize),
return {
syncAtomsToClient = syncAtomsToClient,
unsyncClient = unsyncClient,
unsyncAtomsFromClient = unsyncAtomsFromClient,
startSync = startSync,
}
previousSnapshot = table.clone(currentSnapshot)
changed = false
for _, player in game:GetService("Players"):GetPlayers() do
callback(player, payload)
end
end, syncRate)
return function()
disconnect()
for _, unsubscribe in subscriptions do
unsubscribe()
end
end
end
function self:_sendPatch(player)
assert(sync, "connect() must be called before _sendPatch()")
if not changed then
return
end
local payload: SyncPayload = {
type = "patch",
data = patch.diff(previousSnapshot, currentSnapshot, autoSerialize),
}
previousSnapshot = table.clone(currentSnapshot)
changed = false
sync(player, payload)
end
return self
end
return server

View file

@ -1,21 +1,17 @@
local Charm = require("../Charm")
type Atom<T> = Charm.Atom<T>
--- A payload that can be sent from the server to the client to synchronize
--- state between the two.
--- the states of the client's atoms with the server.
export type SyncPayload =
| {
type: "init",
data: { [string | number]: any },
data: { [string]: any },
}
| {
type: "patch",
data: { [string | number]: any },
data: { [string]: any },
}
export type AtomMap = {
[string | number]: Atom<any>,
}
--- A special type that represents a removed value in a patch. This is used to
--- differentiate between a value that was set to `nil` and an unchanged value.
export type None = { __none: "__none" }
return nil

View file

@ -2,25 +2,26 @@ local SAFE_KEYS = { string = true, number = true }
local UNSAFE_VALUES = { ["function"] = true, thread = true }
local function isTableUnsafe(object: { [any]: any })
local keyType = nil
local objectSize = 0
local key = next(object)
-- All keys must have the same type
for key in next, object do
local currentType = type(key)
if key == nil then
-- An empty table is always safe
return false
end
if not keyType and SAFE_KEYS[currentType] then
keyType = currentType
elseif keyType ~= currentType then
local keyType = type(key)
local keyCount = 0
-- Keys must either be all strings or all numbers
for key in object do
if type(key) ~= keyType then
return true
end
objectSize += 1
keyCount += 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
-- Array keys must be sequential
if keyType == "number" and keyCount > #object then
return true
end
@ -33,19 +34,21 @@ end
--- @param key The key of the value in the table.
--- @error Throws an error if the value cannot be synced.
local function validate(value: any, key: any)
local typeOfKey = type(key)
local typeOfValue = type(value)
local typeKey = type(key)
local typeValue = type(value)
if not SAFE_KEYS[typeOfKey] then
error(`Invalid key type '{typeOfKey}' at key '{key}'`)
elseif UNSAFE_VALUES[typeOfValue] then
error(`Invalid value type '{typeOfValue}' at key '{key}'`)
elseif typeOfValue == "table" then
if not SAFE_KEYS[typeKey] then
error(`Invalid key type '{typeKey}' at key '{key}'`)
elseif UNSAFE_VALUES[typeValue] then
error(`Invalid value type '{typeValue}' at key '{key}'`)
end
if typeValue == "table" then
if getmetatable(value) ~= nil then
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
elseif isTableUnsafe(value) then
error(
`Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n`
`Cannot sync tables unsupported by remote events! The table was found at 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"
@ -54,7 +57,7 @@ local function validate(value: any, key: any)
end
end
if typeOfKey == "number" then
if typeKey == "number" then
if key == math.huge or key == -math.huge then
error("Cannot sync infinity as key")
elseif key ~= math.floor(key) then

View file

@ -1,5 +1,3 @@
--!nolint DeprecatedApi
local batched = require("@self/batched")
local core = require("@self/core")
local flags = require("@self/flags")
@ -9,15 +7,11 @@ local observe = require("@self/observe")
local subscribe = require("@self/subscribe")
local types = require("@self/types")
local untracked = require("@self/untracked")
local utils = require("@self/utils")
export type Atom<T = any> = types.Atom<T>
export type Selector<T = any> = types.Selector<T>
--- @deprecated Function `batch()` is deprecated, use `batched()` instead.
local batch = batched
--- @deprecated Function `peek()` is deprecated, use `untracked()` instead.
local peek = untracked
return {
core = core,
flags = flags,
@ -32,7 +26,5 @@ return {
observe = observe,
subscribe = subscribe,
untracked = untracked,
-- Temporary aliases for backwards compatibility
batch = batch,
peek = peek,
deepFreeze = utils.deepFreeze,
}

View file

@ -8,7 +8,7 @@ suite.describe("charm", function()
require("../packages/charm/src/__tests__/spec")
end)
suite.describe.ingame("charm-sync", function()
suite.describe("charm-sync", function()
require("../packages/charm-sync/src/__tests__/spec")
end)