feat: add serializeArrays option

This commit is contained in:
Richard 2024-11-04 14:39:45 -08:00
parent ecf808c706
commit f386e2c177
22 changed files with 412 additions and 365 deletions

View file

@ -441,7 +441,7 @@ local syncer = CharmSync.server({
atoms = atomsToSync, -- A dictionary of the atoms to sync, matching the client's atoms = atomsToSync, -- A dictionary of the atoms to sync, matching the client's
interval = 0, -- The minimum interval between state updates interval = 0, -- The minimum interval between state updates
preserveHistory = false, -- Whether to send a full history of changes made to the atoms (slower) preserveHistory = false, -- Whether to send a full history of changes made to the atoms (slower)
convertArrays = true, -- Safety measures for arrays, should be false when using ByteNet or Zap serializeArrays = true, -- Safety measures for arrays, should be false when using ByteNet or Zap
}) })
-- Sends state updates to clients when a synced atom changes. -- Sends state updates to clients when a synced atom changes.
@ -467,7 +467,7 @@ end)
- **optional** `preserveHistory`: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If `true`, the server sends multiple payloads instead of one. Defaults to `false` for performance. - **optional** `preserveHistory`: Whether to sync an exhaustive history of changes made to the atoms since the last sync event. If `true`, the server sends multiple payloads instead of one. Defaults to `false` for performance.
- **optional** `convertArrays`: Whether to convert sparse arrays to dictionaries before firing remote events. Because arrays in state patches can have holes, extra work is required to send them over a remote event intact. Defaults to `true`, but should be `false` if payloads are serialized (i.e. if you use [ByteNet](https://github.com/ffrostfall/ByteNet) or [Zap](https://github.com/red-blox/zap)). - **optional** `serializeArrays`: Whether to convert problematic arrays to dictionaries before firing remote events. Because arrays in state patches can have holes, extra work is required to send them over a remote event intact. Defaults to `true`, but should be `false` if payloads are serialized (i.e. if you use [ByteNet](https://github.com/ffrostfall/ByteNet) or [Zap](https://github.com/red-blox/zap)).
#### Returns #### Returns
@ -483,7 +483,7 @@ end)
- **By default, Charm omits the individual changes made to atoms** between sync events (i.e. a `counterAtom` set to `1` and then `2` will only send the final state of `2`). If you need to preserve a history of changes, set `preserveHistory` to `true`. - **By default, Charm omits the individual changes made to atoms** between sync events (i.e. a `counterAtom` set to `1` and then `2` will only send the final state of `2`). If you need to preserve a history of changes, set `preserveHistory` to `true`.
- **Charm does not handle network communication.** You must use remote events to send sync payloads and set `convertArrays` accordingly. This is implemented via the `remotes` namespace in the example above. - **Charm does not handle network communication.** Use remote events or a network library to send sync payloads - and remember to set `serializeArrays` accordingly!
--- ---
@ -524,7 +524,7 @@ remotes.requestState:fire()
#### Caveats #### Caveats
- The client sync object does not handle network communication. You must implement your own network layer to send and receive state updates. This includes requesting the initial state, which is implemented via `requestState` in the example above. - **Charm does not handle network communication.** Use remote events or a network library to receive sync payloads. This includes requesting the initial state, which is implemented via `requestState` in the example above.
--- ---

View file

@ -1,3 +0,0 @@
{
"languageMode": "strict"
}

View file

@ -1,6 +1,5 @@
local Charm = require(script.Parent.Parent.Charm) local Charm = require(script.Parent.Parent.Charm)
local packets = require(script.Parent.packets) local patch = require(script.Parent.patch)
type Packet = packets.Packet
local types = require(script.Parent.types) local types = require(script.Parent.types)
type SyncPayload = types.SyncPayload type SyncPayload = types.SyncPayload
@ -49,14 +48,14 @@ local function client(options: ClientOptions): ClientSyncer
end end
end end
local function patch(data: { Packet }) local function apply(data: { [string | number]: any })
local target = {} local target = {}
for key, atom in next, atoms do for key, atom in next, atoms do
target[key] = atom() target[key] = atom()
end end
target = packets.apply(target, data) target = patch.apply(target, data)
for key, atom in next, atoms do for key, atom in next, atoms do
atom(target[key]) atom(target[key])
@ -71,7 +70,7 @@ local function client(options: ClientOptions): ClientSyncer
if payload.type == "init" then if payload.type == "init" then
hydrate(payload.data) hydrate(payload.data)
elseif not ignoreUnhydrated or hydrated then elseif not ignoreUnhydrated or hydrated then
patch(payload.data) apply(payload.data)
end end
end) end)
end end

View file

@ -22,6 +22,15 @@ declare namespace CharmSync {
readonly [P in keyof T]: T[P] extends Selector<infer State> ? State : never; readonly [P in keyof T]: T[P] extends Selector<infer State> ? State : never;
}; };
/**
* Represents the removal of a value from the state.
*/
interface None {
readonly __none: "__none";
}
type MaybeNone<T> = undefined extends T ? None : never;
/** /**
* Creates a `ClientSyncer` object that receives patches from the server and * Creates a `ClientSyncer` object that receives patches from the server and
* applies them to the local state. * applies them to the local state.
@ -43,19 +52,43 @@ declare namespace CharmSync {
function server<Selectors extends SelectorMap>(options: ServerOptions<Selectors>): ServerSyncer<Selectors>; function server<Selectors extends SelectorMap>(options: ServerOptions<Selectors>): ServerSyncer<Selectors>;
/** /**
* A type that should not be made partial in patches. * Checks whether a value is `None`. If `true`, the value is scheduled to be
* removed from the state when the patch is applied.
*
* @param value The value to check.
* @returns `true` if the value is `None`, otherwise `false`.
*/ */
type DataType = CheckableTypes[keyof Omit<CheckableTypes, "table" | "userdata" | "nil">]; function isNone(value: unknown): value is None;
/** type DataTypes = {
* A packet representing a modified value in the state. [P in keyof CheckableTypes as P extends keyof CheckablePrimitives ? never : P]: CheckableTypes[P];
*/
type SyncPacket<Selectors extends SelectorMap> = {
readonly path: SyncPacketPath<StateOfMap<Selectors>>;
readonly value: unknown;
}; };
type SyncPacketPath<T> = T extends DataType ? readonly [] : readonly [keyof T, ...SyncPacketPath<T[keyof T]>]; /**
* A type that should not be made partial in patches.
*/
type DataType = DataTypes[keyof DataTypes];
/**
* A partial patch that can be applied to the state to update it. Represents
* the difference between the current state and the next state.
*
* If a value was removed, it is replaced with `None`. This can be checked
* using the `sync.isNone` function.
*/
type SyncPatch<State> =
| MaybeNone<State>
| (State extends ReadonlyMap<infer K, infer V> | Map<infer K, infer V>
? ReadonlyMap<K, SyncPatch<V> | None>
: State extends Set<infer T> | ReadonlySet<infer T>
? ReadonlyMap<T, true | None>
: State extends readonly (infer T)[]
? readonly (SyncPatch<T> | None | undefined)[]
: State extends DataType
? State
: State extends object
? { readonly [P in keyof State]?: SyncPatch<State[P]> }
: State);
/** /**
* A payload that can be sent from the server to the client to synchronize * A payload that can be sent from the server to the client to synchronize
@ -63,7 +96,7 @@ declare namespace CharmSync {
*/ */
type SyncPayload<Selectors extends SelectorMap> = type SyncPayload<Selectors extends SelectorMap> =
| { readonly type: "init"; readonly data: StateOfMap<Selectors> } | { readonly type: "init"; readonly data: StateOfMap<Selectors> }
| { readonly type: "patch"; readonly data: readonly SyncPacket<Selectors>[] }; | { readonly type: "patch"; readonly data: SyncPatch<StateOfMap<Selectors>> };
interface ClientOptions<Atoms extends AtomMap> { interface ClientOptions<Atoms extends AtomMap> {
/** /**
@ -98,6 +131,15 @@ declare namespace CharmSync {
* to reconstruct the state's changes over time. * to reconstruct the state's changes over time.
*/ */
preserveHistory?: boolean; preserveHistory?: boolean;
/**
* When `true`, convert problematic sparse arrays into dictionaries
* with string keys. Defaults to `true`.
*
* While this is mandatory for safely syncing arrays over vanilla Roblox
* remotes, it can be disabled if your network library uses a custom
* serialization method (i.e. Zap, ByteNet).
*/
serializeArrays?: boolean;
} }
interface ClientSyncer<Atoms extends AtomMap> { interface ClientSyncer<Atoms extends AtomMap> {

View file

@ -1,10 +1,9 @@
local client = require(script.client) local client = require(script.client)
local packets = require(script.packets) local patch = require(script.patch)
local server = require(script.server) local server = require(script.server)
local types = require(script.types) local types = require(script.types)
export type SyncPayload = types.SyncPayload export type SyncPayload = types.SyncPayload
export type Packet = packets.Packet
--[=[ --[=[
Synchronizes state between the client and server. The server sends patches Synchronizes state between the client and server. The server sends patches
@ -13,4 +12,5 @@ export type Packet = packets.Packet
return { return {
client = client, client = client,
server = server, server = server,
isNone = patch.isNone,
} }

View file

@ -1,100 +0,0 @@
export type Packet = {
path: { string | number },
data: any,
}
local packets: { Packet }
local counter: number
local function push(path: { string | number }, key: string | number)
local result = table.clone(path)
table.insert(result, key)
return result
end
local function append(path: { string | number }, data: unknown)
packets[counter] = { path = path, data = data }
counter += 1
end
local function visit(
previousState: { [string | number]: any },
currentState: { [string | number]: any },
path: { string | number }
)
local empty = next(currentState) == nil
if empty then
-- Overwrite with an empty table instead of deleting keys
return append(path, currentState)
end
-- Search keys for removals and changes, but not additions
for key, previous in next, previousState do
local current = currentState[key]
if current == previous then
continue
elseif current == nil then
append(push(path, key), nil)
elseif type(current) == "table" and type(previous) == "table" then
visit(previous, current, push(path, key))
else
append(push(path, key), current)
end
end
-- Search for additions not present in the previous state
for key, current in next, currentState do
if previousState[key] == nil then
append(push(path, key), current)
end
end
end
local function diff(previous: { [string | number]: any }, current: { [string | number]: any })
packets = {}
counter = 1
visit(previous, current, {})
return packets
end
local function apply(state: { [string | number]: any }, packets: { Packet })
local result = table.clone(state)
local copied = {}
local function resolve(path: { string | number })
local object = result
local length = #path
local id = ""
for index = 1, length - 1 do
local key = path[index]
id ..= "\\" .. tostring(key)
-- Prepare a deep copy of the object if it hasn't been accessed yet
if not copied[id] then
copied[id] = true
object[key] = table.clone(object[key])
end
object = object[key]
end
return object, path[length]
end
for _, packet in next, packets do
local object, key = resolve(packet.path)
object[key] = packet.data
end
return result
end
return {
diff = diff,
apply = apply,
}

View file

@ -0,0 +1,121 @@
local validate = require(script.Parent.validate)
local __DEV__ = _G.__DEV__
--[=[
A special symbol that denotes the absence of a value. Used to represent
deleted values in patches.
]=]
local None = { __none = "__none" }
local function isNone(value: any): boolean
return type(value) == "table" and value.__none == "__none"
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
dictionaries with string keys.
The `apply` function will convert these dictionaries back into arrays as
long as the target array is not sparse. This function ensures that the diff
function produces a patch that can be correctly applied.
]=]
local function stringifySparseArray(object: { any }): { [string | number]: any }
local maxn = table.maxn(object)
if maxn == 0 or maxn == #object then
-- The object is either not an array or a dense array
return object
end
local dictionary = {}
for index, value in next, object do
dictionary[tostring(index)] = value
end
return dictionary
end
--[=[
Compares two states and returns a patch that can be applied to the previous
state to produce the next state.
@param prevState The previous state.
@param nextState The next state.
@param serializeArrays 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 }, serializeArrays: boolean?)
local patches = table.clone(nextState)
for key, previous in next, 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, serializeArrays)
end
end
-- Coerce sparse array patches into dictionaries
if serializeArrays ~= false and (prevState[1] ~= nil or nextState[1] ~= nil) then
patches = stringifySparseArray(patches)
end
if __DEV__ then
for key, value in next, prevState do
validate(value, key)
end
for key, value in nextState do
if prevState[key] ~= value then
validate(value, key)
end
end
end
return patches
end
--[=[
Applies a patch to a state and returns the next state.
@param state The current state.
@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
return nil
elseif type(state) ~= "table" or type(patches) ~= "table" then
return patches
end
local nextState = table.clone(state)
local stateIsArray = state[1] ~= nil
for key, patch in next, 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.
if stateIsArray and type(key) == "string" then
key = tonumber(key) or key
end
nextState[key] = apply(nextState[key], patch)
end
return nextState
end
return {
isNone = isNone,
diff = diff,
apply = apply,
}

View file

@ -2,8 +2,7 @@ local Players = game:GetService("Players")
local Charm = require(script.Parent.Parent.Charm) local Charm = require(script.Parent.Parent.Charm)
local interval = require(script.Parent.interval) local interval = require(script.Parent.interval)
local packets = require(script.Parent.packets) local patch = require(script.Parent.patch)
type Packet = packets.Packet
local types = require(script.Parent.types) local types = require(script.Parent.types)
type SyncPayload = types.SyncPayload type SyncPayload = types.SyncPayload
@ -24,6 +23,15 @@ type ServerOptions = {
per frame, where each individual change is important. Defaults to `false`. per frame, where each individual change is important. Defaults to `false`.
]=] ]=]
preserveHistory: boolean?, preserveHistory: boolean?,
--[=[
Whether to convert problematic sparse arrays into dictionaries with
string keys. Defaults to `true`.
While this is mandatory for safely syncing arrays over vanilla Roblox
remotes, it can be disabled if your network library uses a custom
serialization method (i.e. Zap, ByteNet).
]=]
serializeArrays: boolean?,
} }
type ServerSyncer = { type ServerSyncer = {
@ -136,7 +144,7 @@ local function server(options: ServerOptions): ServerSyncer
table.insert(payloads, { table.insert(payloads, {
type = "patch", type = "patch",
data = packets.diff(snapshots[index - 1], snapshot), data = patch.diff(snapshots[index - 1], snapshot, options.serializeArrays),
}) })
end end
@ -173,7 +181,7 @@ local function server(options: ServerOptions): ServerSyncer
table.insert(payloads, { table.insert(payloads, {
type = "patch", type = "patch",
data = packets.diff(snapshots[index - 1], snapshot), data = patch.diff(snapshots[index - 1], snapshot, options.serializeArrays),
}) })
end end
@ -210,7 +218,7 @@ local function server(options: ServerOptions): ServerSyncer
local payload: SyncPayload = { local payload: SyncPayload = {
type = "patch", type = "patch",
data = packets.diff(previousSnapshot, currentSnapshot), data = patch.diff(previousSnapshot, currentSnapshot, options.serializeArrays),
} }
previousSnapshot = table.clone(currentSnapshot) previousSnapshot = table.clone(currentSnapshot)
@ -239,7 +247,7 @@ local function server(options: ServerOptions): ServerSyncer
local payload: SyncPayload = { local payload: SyncPayload = {
type = "patch", type = "patch",
data = packets.diff(previousSnapshot, currentSnapshot), data = patch.diff(previousSnapshot, currentSnapshot, options.serializeArrays),
} }
previousSnapshot = table.clone(currentSnapshot) previousSnapshot = table.clone(currentSnapshot)

View file

@ -1,6 +1,3 @@
local packets = require(script.Parent.packets)
type Packet = packets.Packet
--[=[ --[=[
A payload that can be sent from the server to the client to synchronize A payload that can be sent from the server to the client to synchronize
state between the two. state between the two.
@ -10,7 +7,7 @@ export type SyncPayload = {
data: { [string | number]: any }, data: { [string | number]: any },
} | { } | {
type: "patch", type: "patch",
data: { Packet }, data: { [string | number]: any },
} }
return nil return nil

View file

@ -1,7 +1,5 @@
local Charm = require(script.Parent.Parent.Charm)
local SAFE_KEYS = { string = true, number = true } local SAFE_KEYS = { string = true, number = true }
local SAFE_VALUES = { string = true, number = true, boolean = true, table = true, userdata = true } local SAFE_VALUES = { string = true, number = true, boolean = true, table = true, userdata = true, vector = true }
local function isTableUnsafe(object: { [any]: any }) local function isTableUnsafe(object: { [any]: any })
local keyType = nil local keyType = nil
@ -47,8 +45,6 @@ local function validate(value: any, key: any)
elseif typeOfValue == "table" then elseif typeOfValue == "table" then
if getmetatable(value) ~= nil then if getmetatable(value) ~= nil then
error(`Cannot sync tables with metatables! Got {value} at key '{key}'`) error(`Cannot sync tables with metatables! Got {value} at key '{key}'`)
elseif Charm.isAtom(value) then
error(`Cannot sync nested atoms! Got atom at key '{key}'`)
elseif isTableUnsafe(value) then elseif isTableUnsafe(value) then
error( error(
`Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n` `Cannot sync tables unsupported by remote events! The value has the key '{key}'.\n\n`
@ -69,7 +65,7 @@ local function validate(value: any, key: any)
elseif typeOfKey == "number" then elseif typeOfKey == "number" then
if key == math.huge or key == -math.huge then if key == math.huge or key == -math.huge then
error("Cannot sync infinity as key") error("Cannot sync infinity as key")
elseif math.floor(key) ~= key then elseif key ~= math.floor(key) then
error("Cannot sync non-integer number as key") error("Cannot sync non-integer number as key")
end end
end end

View file

@ -1,3 +0,0 @@
{
"languageMode": "strict"
}

View file

@ -3,6 +3,8 @@ type Atom<T> = types.Atom<T>
type Set<T> = types.Set<T> type Set<T> = types.Set<T>
type WeakMap<K, V> = types.WeakMap<K, V> type WeakMap<K, V> = types.WeakMap<K, V>
local __DEV__ = _G.__DEV__
local listeners: WeakMap<Atom<any>, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" }) local listeners: WeakMap<Atom<any>, WeakMap<() -> (), unknown>> = setmetatable({}, { __mode = "k" })
local batched: Set<() -> ()> = {} local batched: Set<() -> ()> = {}
@ -24,7 +26,7 @@ local capturing = {
@return The result of the callback. @return The result of the callback.
]=] ]=]
local function try<T, U...>(callback: (U...) -> T | any, finally: (() -> ())?, ...: U...): T local function try<T, U...>(callback: (U...) -> T | any, finally: (() -> ())?, ...: U...): T
if _G.__DEV__ then if __DEV__ then
local thread = coroutine.create(callback) local thread = coroutine.create(callback)
local success, result = coroutine.resume(thread, ...) local success, result = coroutine.resume(thread, ...)

View file

@ -1,3 +0,0 @@
{
"languageMode": "strict"
}

View file

@ -1,3 +0,0 @@
{
"languageMode": "strict"
}

View file

@ -9,10 +9,12 @@ local Vide = if script.Parent:FindFirstChild("vide")
@param callback The atom or selector to subscribe to. @param callback The atom or selector to subscribe to.
@return The reactive source. @return The reactive source.
]=] ]=]
local function useAtom<State>(callback: () -> State): State local function useAtom<State>(callback: () -> State): () -> State
local state = Vide.source(callback()) local state = Vide.source(callback())
local unsubscribe = Charm.subscribe(callback, function(value) local unsubscribe = Charm.subscribe(callback, function(value)
task.spawn(state, value) task.spawn(function()
state(value)
end)
end) end)
Vide.cleanup(unsubscribe) Vide.cleanup(unsubscribe)

View file

@ -19,30 +19,6 @@ return function()
expect(a()).to.equal(2) expect(a()).to.equal(2)
end) end)
it("requires init by default", function()
local a = atom(1)
local client = client({ atoms = { a = a } })
client:sync({
type = "patch",
data = { { path = { "a" }, data = 2 } },
})
expect(a()).to.equal(1)
end)
it("can ignore init requirement", function()
local a = atom(1)
local client = client({ atoms = { a = a }, ignoreUnhydrated = false })
client:sync({
type = "patch",
data = { { path = { "a" }, data = 2 } },
})
expect(a()).to.equal(2)
end)
it("syncs state patch", function() it("syncs state patch", function()
local a = atom({ b = 1, c = 2 }) local a = atom({ b = 1, c = 2 })
local previous = a() local previous = a()
@ -57,7 +33,7 @@ return function()
client:sync({ client:sync({
type = "patch", type = "patch",
data = { { path = { "a", "b" }, data = 3 } }, data = { a = { b = 3 } },
}) })
expect(a()).to.never.equal(previous) expect(a()).to.never.equal(previous)
@ -65,6 +41,27 @@ return function()
expect(a().c).to.equal(2) expect(a().c).to.equal(2)
end) end)
it("fixes sparse array patches", function()
local a = atom({ 1 })
local client = client({ atoms = { a = a } })
client:sync({
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 } },
})
expect(a()["2" :: any]).to.never.be.ok()
expect(a()[2]).to.equal(2)
end)
it("applies multiple patches", function() it("applies multiple patches", function()
local a = atom(1) local a = atom(1)
local client = client({ atoms = { a = a } }) local client = client({ atoms = { a = a } })
@ -81,10 +78,10 @@ return function()
client:sync({ client:sync({
type = "patch", type = "patch",
data = { { path = { "a" }, data = 2 } }, data = { a = 2 },
}, { }, {
type = "patch", type = "patch",
data = { { path = { "a" }, data = 3 } }, data = { a = 3 },
}) })
expect(a()).to.equal(3) expect(a()).to.equal(3)
@ -113,10 +110,7 @@ return function()
client:sync({ client:sync({
type = "patch", type = "patch",
data = { data = { a = 1, b = 2 },
{ path = { "a" }, data = 1 },
{ path = { "b" }, data = 2 },
},
}) })
expect(a()).to.equal(1) expect(a()).to.equal(1)

View file

@ -1,68 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local packets = require(ReplicatedStorage.DevPackages.CharmSync.packets)
return function()
it("creates a packet", function()
local prevState = { foo = "bar", baz = "qux" }
local nextState = { foo = "bar", baz = "quux" }
local results = packets.diff(prevState, nextState)
expect(#results).to.equal(1)
expect(table.concat(results[1].path, "/")).to.equal("baz")
expect(results[1].data).to.equal("quux")
end)
it("applies a packet", function()
local prevState = { foo = "bar", baz = "qux" }
local packet: packets.Packet = { path = { "baz" }, data = "quux" }
local nextState = packets.apply(prevState, { packet })
expect(nextState.baz).to.equal("quux")
expect(nextState.foo).to.equal("bar")
end)
it("applies nested packets", function()
local prevState = { foo = { bar = "baz" } }
local nextState = { foo = { bar = "qux" } }
local results = packets.diff(prevState, nextState)
local applied = packets.apply(prevState, results)
expect(prevState.foo.bar).to.equal("baz")
expect(applied.foo.bar).to.equal("qux")
end)
it("only copies changed tables", function()
local prevState = { foo = { bar = "baz" }, baz = { qux = "quux" } }
local nextState = { foo = { bar = "qux" }, baz = table.clone(prevState.baz) }
local results = packets.diff(prevState, nextState)
local applied = packets.apply(prevState, results)
expect(applied).to.never.equal(prevState)
expect(applied.foo).to.never.equal(prevState.foo)
expect(applied.foo.bar).to.equal("qux")
expect(applied.baz).to.equal(prevState.baz)
end)
it("excludes unchanged tables", function()
local prevState = { foo = { bar = "baz" }, baz = { qux = "quux" } }
local nextState = { foo = { bar = "qux" }, baz = table.clone(prevState.baz) }
local results = packets.diff(prevState, nextState)
expect(#results).to.equal(1)
expect(table.concat(results[1].path, "/")).to.equal("foo/bar")
expect(results[1].data).to.equal("qux")
end)
it("overwrites cleared tables", function()
local prevState = { foo = { 1, 2, 3 } }
local nextState = { foo = {} }
local results = packets.diff(prevState, nextState)
expect(#results).to.equal(1)
expect(table.concat(results[1].path, "/")).to.equal("foo")
expect(#results[1].data).to.equal(0)
end)
end

View file

@ -0,0 +1,111 @@
local HttpService = game:GetService("HttpService")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch)
return function()
local function serdes(value: { [any]: any })
return HttpService:JSONDecode(HttpService:JSONEncode(value))
end
it("returns a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local nextState = { foo = "bar", baz = "quux" }
local patches = patch.diff(prevState, nextState)
expect(patches).to.be.ok()
expect(patches.baz).to.equal("quux")
expect(patches.foo).to.never.be.ok()
end)
it("applies a patch", function()
local prevState = { foo = "bar", baz = "qux" }
local patches = { baz = "quux", qaz = "qux" }
local applied = patch.apply(prevState, patches)
expect(applied).to.be.ok()
expect(applied.baz).to.equal("quux")
expect(applied.foo).to.equal("bar")
expect(applied.qaz).to.equal("qux")
end)
it("allows nested patches", function()
local prevState = { foo = { bar = "baz" } }
local nextState = { foo = { bar = "qux" } }
local patches = patch.diff(prevState, nextState)
local applied = patch.apply(prevState, patches)
expect(applied.foo.bar).to.equal("qux")
end)
it("preserves array density when applying", function()
local prevState = { "a", "b", "c", "d" }
local nextState = { ["2"] = "B", ["4"] = "D" }
local applied = patch.apply(prevState, nextState)
expect(applied[1]).to.equal("a")
expect(applied[2]).to.equal("B")
expect(applied[3]).to.equal("c")
expect(applied[4]).to.equal("D")
expect(applied["2"]).to.never.be.ok()
expect(applied["4"]).to.never.be.ok()
end)
it("preserves array density after serdes", function()
-- This particular change results in a patch that cannot be serialized
-- without losing information. Charm resolves this issue internally.
local prevState = { "a", "b", "c", "d", "e" }
local nextState = { "A", "b", "c", "D" }
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
for index in next, prevState do
expect(applied[index]).to.equal(nextState[index])
end
for index in next, applied do
expect(applied[index]).to.equal(nextState[index])
end
end)
it("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
expect(applied[index]).to.equal(nextState[index])
end
end)
it("cannot preserve sparse arrays", function()
local prevState = {}
local nextState = { [2] = "foo" }
local patches = serdes(patch.diff(prevState, nextState))
local applied = patch.apply(prevState, patches)
-- Not enough information is available to determine whether 'patches'
-- should keep the string keys added during remote event serialization.
expect(applied["2"]).to.be.ok()
expect(applied[2]).to.never.be.ok()
end)
it("optionally fixes sparse arrays", function()
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }) do
expect(index).to.be.a("string")
end
for index in next, patch.diff({ 1, 1, 1, 1, 1 }, { 2, 1, 1, 2 }, false) do
expect(index).to.be.a("number")
end
end)
end

View file

@ -5,6 +5,7 @@ local atom = Charm.atom
local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync) local CharmSync = require(ReplicatedStorage.DevPackages.CharmSync)
type SyncPayload = CharmSync.SyncPayload type SyncPayload = CharmSync.SyncPayload
local server = CharmSync.server local server = CharmSync.server
local patch = require(ReplicatedStorage.DevPackages.CharmSync.patch)
return function() return function()
local target: Player = {} :: any local target: Player = {} :: any
@ -21,14 +22,14 @@ return function()
server:hydrate(target) server:hydrate(target)
expect(player).to.equal(target) expect(player).to.equal(target)
assert(payload.type == "init", "payload.type is not 'init'") expect(payload.type).to.equal("init")
expect(payload.data.a).to.equal(1) expect(payload.data.a).to.equal(1)
disconnect() disconnect()
end) end)
it("sends state patches", function() it("sends state patches", function()
local a = atom({ 1, 2 }) local a = atom({ b = 1, c = 2 } :: { [string]: number? })
local server = server({ atoms = { a = a }, interval = -1 }) local server = server({ atoms = { a = a }, interval = -1 })
local player: Player, payload: SyncPayload local player: Player, payload: SyncPayload
@ -36,27 +37,20 @@ return function()
player, payload = ... player, payload = ...
end) end)
a({ 1, 5 }) a({ b = 1, c = 5 })
server:_sendPatch(target) server:_sendPatch(target)
expect(player).to.equal(target) expect(player).to.equal(target)
assert(payload.type == "patch", "payload.type is not 'patch'") expect(payload.type).to.equal("patch")
expect(#payload.data).to.equal(1) expect(payload.data.a.b).to.never.be.ok()
expect(payload.data.a.c).to.equal(5)
expect(table.concat(payload.data[1].path, "/")).to.equal("a/2") a({ b = 1, d = 1 })
expect(payload.data[1].data).to.equal(5)
a({ 1, nil :: any, 1 })
server:_sendPatch(target) server:_sendPatch(target)
assert(payload.type == "patch", "payload.type is not 'patch'") expect(payload.data.a.b).to.never.be.ok()
expect(#payload.data).to.equal(2) expect(patch.isNone(payload.data.a.c)).to.equal(true)
expect(payload.data.a.d).to.equal(1)
expect(table.concat(payload.data[1].path, "/")).to.equal("a/2")
expect(payload.data[1].data).to.never.be.ok()
expect(table.concat(payload.data[2].path, "/")).to.equal("a/3")
expect(payload.data[2].data).to.equal(1)
disconnect() disconnect()
end) end)
@ -81,16 +75,9 @@ return function()
server:_sendPatch(target) server:_sendPatch(target)
expect(#payloads).to.equal(1) expect(#payloads).to.equal(1)
assert(payloads[1].type == "patch", "payload 1 type is not 'patch'") expect(payloads[1].type).to.equal("patch")
expect(#payloads[1].data).to.equal(2) expect(payloads[1].data.a).to.equal(2)
expect(payloads[1].data.b).to.equal(2)
-- a(2)
expect(table.concat(payloads[1].data[1].path, "/")).to.equal("a")
expect(payloads[1].data[1].data).to.equal(2)
-- b(2)
expect(table.concat(payloads[1].data[2].path, "/")).to.equal("b")
expect(payloads[1].data[2].data).to.equal(2)
a(3) a(3)
a(4) a(4)
@ -98,24 +85,12 @@ return function()
server:_sendPatch(target) server:_sendPatch(target)
expect(#payloads).to.equal(2) expect(#payloads).to.equal(2)
assert(payloads[1].type == "patch", "payload 1 type is not 'patch'") expect(payloads[1].type).to.equal("patch")
expect(#payloads[1].data).to.equal(1) expect(payloads[1].data.a).to.equal(3)
expect(payloads[1].data.b).to.never.be.ok()
-- a(3) expect(payloads[2].type).to.equal("patch")
expect(table.concat(payloads[1].data[1].path, "/")).to.equal("a") expect(payloads[2].data.a).to.equal(4)
expect(payloads[1].data[1].data).to.equal(3) expect(payloads[2].data.b).to.equal(3)
-- second a() call cannot be collapsed, so it is added to a new payload
assert(payloads[2].type == "patch", "payload 2 type is not 'patch'")
expect(#payloads[2].data).to.equal(2)
-- a(4)
expect(table.concat(payloads[2].data[1].path, "/")).to.equal("a")
expect(payloads[2].data[1].data).to.equal(4)
-- b(3)
expect(table.concat(payloads[2].data[2].path, "/")).to.equal("b")
expect(payloads[2].data[2].data).to.equal(3)
disconnect() disconnect()
end) end)

View file

@ -1,19 +0,0 @@
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function()
beforeAll(function()
_G.__DEV__ = true
end)
afterAll(function()
_G.__DEV__ = nil
end)
afterEach(function()
table.clear(store.listeners :: any)
table.clear(store.capturing.stack)
store.capturing.index = 0
end)
end

View file

@ -1,5 +1,12 @@
--!nocheck
local ReplicatedStorage = game:GetService("ReplicatedStorage") local ReplicatedStorage = game:GetService("ReplicatedStorage")
local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ) local TestEZ = require(ReplicatedStorage.DevPackages.TestEZ)
-- Start Charm in debug mode
_G.__DEV__ = true
require(ReplicatedStorage.DevPackages.Charm)
_G.__DEV__ = false
TestEZ.TestBootstrap:run({ script.Parent }, TestEZ.Reporters.TextReporter) TestEZ.TestBootstrap:run({ script.Parent }, TestEZ.Reporters.TextReporter)

View file

@ -3,14 +3,6 @@ local ReplicatedStorage = game:GetService("ReplicatedStorage")
local store = require(ReplicatedStorage.DevPackages.Charm.store) local store = require(ReplicatedStorage.DevPackages.Charm.store)
return function() return function()
beforeAll(function()
_G.__DEV__ = true
end)
afterAll(function()
_G.__DEV__ = nil
end)
afterEach(function() afterEach(function()
table.clear(store.listeners :: any) table.clear(store.listeners :: any)
table.clear(store.capturing.stack) table.clear(store.capturing.stack)