mirror of
https://github.com/littensy/charm.git
synced 2026-10-22 12:41:37 +00:00
refactor: update charm sync api
This commit is contained in:
parent
1e0b10aef6
commit
32788b19e1
13 changed files with 359 additions and 335 deletions
|
|
@ -2,8 +2,6 @@ local Charm = require("../../Charm")
|
||||||
local client = require("../client")
|
local client = require("../client")
|
||||||
local suite = require("../../../../test/suite")
|
local suite = require("../../../../test/suite")
|
||||||
|
|
||||||
local atom = Charm.atom
|
|
||||||
local subscribe = Charm.subscribe
|
|
||||||
local test = suite.test
|
local test = suite.test
|
||||||
local afterEach = suite.afterEach
|
local afterEach = suite.afterEach
|
||||||
|
|
||||||
|
|
@ -11,29 +9,29 @@ afterEach(function()
|
||||||
client.reset()
|
client.reset()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should sync state payload", function()
|
test("should hydrate atom", function()
|
||||||
local a = atom(1)
|
local a = Charm.atom(1)
|
||||||
|
|
||||||
client.syncAtoms({ a = a })
|
client.register({ a = a })
|
||||||
client.applyPayloads({
|
client.hydrate({
|
||||||
{ type = "init", data = { a = 2 } },
|
{ type = "init", data = { a = 2 } },
|
||||||
})
|
})
|
||||||
|
|
||||||
assert(a() == 2, "should set a to the initial value")
|
assert(a() == 2, "should set a to the initial value")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should sync state patch", function()
|
test("should merge patch", function()
|
||||||
local a = atom({ b = 1, c = 2 })
|
local a = Charm.atom({ b = 1, c = 2 })
|
||||||
local previous = a()
|
local previous = a()
|
||||||
|
|
||||||
client.syncAtoms({ a = a })
|
client.register({ a = a })
|
||||||
client.applyPayloads({
|
client.hydrate({
|
||||||
{ type = "init", data = { a = a() } },
|
{ type = "init", data = { a = a() } },
|
||||||
})
|
})
|
||||||
|
|
||||||
assert(a() == previous, "should set a to the initial value")
|
assert(a() == previous, "should set a to the initial value")
|
||||||
|
|
||||||
client.applyPayloads({
|
client.hydrate({
|
||||||
{ type = "patch", data = { a = { b = 3 } } },
|
{ type = "patch", data = { a = { b = 3 } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -43,17 +41,17 @@ test("should sync state patch", function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should fix sparse array patches", function()
|
test("should fix sparse array patches", function()
|
||||||
local a = atom({ 1 })
|
local a = Charm.atom({ 1 })
|
||||||
|
|
||||||
client.syncAtoms({ a = a })
|
client.register({ a = a })
|
||||||
client.applyPayloads({
|
client.hydrate({
|
||||||
{ type = "init", data = { a = a() } },
|
{ type = "init", data = { a = a() } },
|
||||||
})
|
})
|
||||||
|
|
||||||
-- This patch would be the result of a diff between { 1 } and { 1, 2 } that
|
-- 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
|
-- was JSON-serialized and deserialized. The client should be able to catch
|
||||||
-- this and convert the string key back to a number.
|
-- this and convert the string key back to a number.
|
||||||
client.applyPayloads({
|
client.hydrate({
|
||||||
{ type = "patch", data = { a = { ["2"] = 2 } } },
|
{ type = "patch", data = { a = { ["2"] = 2 } } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -61,21 +59,21 @@ test("should fix sparse array patches", function()
|
||||||
assert(a()[2] == 2, "should convert serialized arrays to use numbers")
|
assert(a()[2] == 2, "should convert serialized arrays to use numbers")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should apply multiple patches", function()
|
test("should merge multiple patches", function()
|
||||||
local a = atom(1)
|
local a = Charm.atom(1)
|
||||||
local calls = {}
|
local calls = {}
|
||||||
|
|
||||||
client.syncAtoms({ a = a })
|
client.register({ a = a })
|
||||||
|
|
||||||
subscribe(a, function(value)
|
Charm.subscribe(a, function(value)
|
||||||
table.insert(calls, value)
|
table.insert(calls, value)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
client.applyPayloads({
|
client.hydrate({
|
||||||
{ type = "init", data = { a = a() } },
|
{ type = "init", data = { a = a() } },
|
||||||
})
|
})
|
||||||
|
|
||||||
client.applyPayloads({
|
client.hydrate({
|
||||||
{ type = "patch", data = { a = 2 } },
|
{ type = "patch", data = { a = 2 } },
|
||||||
{ type = "patch", data = { a = 3 } },
|
{ type = "patch", data = { a = 3 } },
|
||||||
})
|
})
|
||||||
|
|
@ -85,25 +83,25 @@ test("should apply multiple patches", function()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
test("should batch patches", function()
|
test("should batch patches", function()
|
||||||
local a = atom(0)
|
local a = Charm.atom(0)
|
||||||
local b = atom(0)
|
local b = Charm.atom(0)
|
||||||
local states = {}
|
local states = {}
|
||||||
|
|
||||||
client.syncAtoms({ a = a, b = b })
|
client.register({ a = a, b = b })
|
||||||
|
|
||||||
subscribe(a, function()
|
Charm.subscribe(a, function()
|
||||||
states.b = b()
|
states.b = b()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
subscribe(b, function()
|
Charm.subscribe(b, function()
|
||||||
states.a = a()
|
states.a = a()
|
||||||
end)
|
end)
|
||||||
|
|
||||||
client.applyPayloads({
|
client.hydrate({
|
||||||
{ type = "init", data = { a = a(), b = b() } },
|
{ type = "init", data = { a = a(), b = b() } },
|
||||||
})
|
})
|
||||||
|
|
||||||
client.applyPayloads({
|
client.hydrate({
|
||||||
{ type = "patch", data = { a = 1, b = 2 } },
|
{ type = "patch", data = { a = 1, b = 2 } },
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,11 +20,11 @@ test("should replicate server state to client", function()
|
||||||
local clientA = Charm.atom({ count = -1 })
|
local clientA = Charm.atom({ count = -1 })
|
||||||
local clientB = Charm.atom({ count = -1 })
|
local clientB = Charm.atom({ count = -1 })
|
||||||
|
|
||||||
client.syncAtoms({ a = clientA, b = clientB })
|
client.register({ a = clientA, b = clientB })
|
||||||
server.syncAtomsToClient(target, { a = serverA, b = serverB })
|
server.subscribeClient(target, { a = serverA, b = serverB })
|
||||||
server.startSync(function(syncTarget, payloads)
|
server.connect(function(syncTarget, payloads)
|
||||||
if syncTarget == target then
|
if syncTarget == target then
|
||||||
client.applyPayloads(payloads)
|
client.hydrate(payloads)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -54,11 +54,11 @@ test("should replicate historical server state to client", function()
|
||||||
|
|
||||||
config.preserveHistory = true
|
config.preserveHistory = true
|
||||||
|
|
||||||
client.syncAtoms({ a = clientA, b = clientB })
|
client.register({ a = clientA, b = clientB })
|
||||||
server.syncAtomsToClient(target, { a = serverA, b = serverB })
|
server.subscribeClient(target, { a = serverA, b = serverB })
|
||||||
server.startSync(function(syncTarget, payloads)
|
server.connect(function(syncTarget, payloads)
|
||||||
if syncTarget == target then
|
if syncTarget == target then
|
||||||
client.applyPayloads(payloads)
|
client.hydrate(payloads)
|
||||||
end
|
end
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ test("should replicate state changes", function()
|
||||||
local c = Charm.atom(0)
|
local c = Charm.atom(0)
|
||||||
local target, payloads
|
local target, payloads
|
||||||
|
|
||||||
server.syncAtomsToClient(client, { a = a, b = b, c = c })
|
server.subscribeClient(client, { a = a, b = b, c = c })
|
||||||
server.startSync(function(...)
|
server.connect(function(...)
|
||||||
target, payloads = ...
|
target, payloads = ...
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -47,8 +47,8 @@ test("should replicate partial state patches", function()
|
||||||
local b = Charm.atom(0 :: number?)
|
local b = Charm.atom(0 :: number?)
|
||||||
local target, payloads
|
local target, payloads
|
||||||
|
|
||||||
server.syncAtomsToClient(client, { a = a, b = b })
|
server.subscribeClient(client, { a = a, b = b })
|
||||||
server.startSync(function(...)
|
server.connect(function(...)
|
||||||
target, payloads = ...
|
target, payloads = ...
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -75,8 +75,8 @@ test("should init new clients", function()
|
||||||
local a = Charm.atom(0)
|
local a = Charm.atom(0)
|
||||||
local payloadsA, payloadsB
|
local payloadsA, payloadsB
|
||||||
|
|
||||||
server.syncAtomsToClient(clientA, { a = a })
|
server.subscribeClient(clientA, { a = a })
|
||||||
server.startSync(function(target, payloads)
|
server.connect(function(target, payloads)
|
||||||
if target == clientA then
|
if target == clientA then
|
||||||
payloadsA = payloads
|
payloadsA = payloads
|
||||||
elseif target == clientB then
|
elseif target == clientB then
|
||||||
|
|
@ -89,7 +89,7 @@ test("should init new clients", function()
|
||||||
assert(payloadsA[1].type == "init", "Expected first payload to be init for client A")
|
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.a == a(), "Expected init payload to contain atom 'a' for client A")
|
||||||
|
|
||||||
server.syncAtomsToClient(clientB, { a = a })
|
server.subscribeClient(clientB, { a = a })
|
||||||
a(2)
|
a(2)
|
||||||
server.flush()
|
server.flush()
|
||||||
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
|
assert(payloadsA[1].type == "patch", "Expected first payload to be patch for client A")
|
||||||
|
|
@ -104,13 +104,13 @@ test("should init new atoms", function()
|
||||||
local b = Charm.atom(0)
|
local b = Charm.atom(0)
|
||||||
local target, payloads
|
local target, payloads
|
||||||
|
|
||||||
server.syncAtomsToClient(client, { a = a })
|
server.subscribeClient(client, { a = a })
|
||||||
server.startSync(function(...)
|
server.connect(function(...)
|
||||||
target, payloads = ...
|
target, payloads = ...
|
||||||
end)
|
end)
|
||||||
|
|
||||||
server.flush()
|
server.flush()
|
||||||
server.syncAtomsToClient(client, { b = b })
|
server.subscribeClient(client, { b = b })
|
||||||
a(1)
|
a(1)
|
||||||
b(1)
|
b(1)
|
||||||
server.flush()
|
server.flush()
|
||||||
|
|
@ -129,9 +129,9 @@ test("should only replicate to synced clients", function()
|
||||||
local b = Charm.atom(0)
|
local b = Charm.atom(0)
|
||||||
local payloadsA, payloadsB
|
local payloadsA, payloadsB
|
||||||
|
|
||||||
server.syncAtomsToClient(clientA, { a = a })
|
server.subscribeClient(clientA, { a = a })
|
||||||
server.syncAtomsToClient(clientB, { b = b })
|
server.subscribeClient(clientB, { b = b })
|
||||||
server.startSync(function(target, payloads)
|
server.connect(function(target, payloads)
|
||||||
if target == clientA then
|
if target == clientA then
|
||||||
payloadsA = payloads
|
payloadsA = payloads
|
||||||
elseif target == clientB then
|
elseif target == clientB then
|
||||||
|
|
@ -170,8 +170,8 @@ test("should validate state", function()
|
||||||
local client = {}
|
local client = {}
|
||||||
local a = Charm.atom({})
|
local a = Charm.atom({})
|
||||||
|
|
||||||
server.syncAtomsToClient(client, { a = a })
|
server.subscribeClient(client, { a = a })
|
||||||
server.startSync(function() end)
|
server.connect(function() end)
|
||||||
|
|
||||||
a({ [0.5] = true })
|
a({ [0.5] = true })
|
||||||
assert(not pcall(server.flush), "Expected server.flush to fail")
|
assert(not pcall(server.flush), "Expected server.flush to fail")
|
||||||
|
|
@ -185,8 +185,8 @@ test("should serialize state", function()
|
||||||
local a = Charm.atom({ 1, 2, 3, 4, 5 })
|
local a = Charm.atom({ 1, 2, 3, 4, 5 })
|
||||||
local payloads
|
local payloads
|
||||||
|
|
||||||
server.syncAtomsToClient(client, { a = a })
|
server.subscribeClient(client, { a = a })
|
||||||
server.startSync(function(_, p)
|
server.connect(function(_, p)
|
||||||
payloads = p
|
payloads = p
|
||||||
end)
|
end)
|
||||||
server.flush()
|
server.flush()
|
||||||
|
|
@ -213,8 +213,8 @@ describe("historical", function()
|
||||||
local c = Charm.atom(0)
|
local c = Charm.atom(0)
|
||||||
local target, payloads
|
local target, payloads
|
||||||
|
|
||||||
server.syncAtomsToClient(client, { a = a, b = b, c = c })
|
server.subscribeClient(client, { a = a, b = b, c = c })
|
||||||
server.startSync(function(...)
|
server.connect(function(...)
|
||||||
target, payloads = ...
|
target, payloads = ...
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -242,8 +242,8 @@ describe("historical", function()
|
||||||
local a = Charm.atom(0)
|
local a = Charm.atom(0)
|
||||||
local payloadsA, payloadsB
|
local payloadsA, payloadsB
|
||||||
|
|
||||||
server.syncAtomsToClient(clientA, { a = a })
|
server.subscribeClient(clientA, { a = a })
|
||||||
server.startSync(function(target, payloads)
|
server.connect(function(target, payloads)
|
||||||
if target == clientA then
|
if target == clientA then
|
||||||
payloadsA = payloads
|
payloadsA = payloads
|
||||||
elseif target == clientB then
|
elseif target == clientB then
|
||||||
|
|
@ -259,7 +259,7 @@ describe("historical", function()
|
||||||
assert(payloadsA[2].type == "patch", "Expected second payload to be patch 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")
|
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 })
|
server.subscribeClient(clientB, { a = a })
|
||||||
a(3)
|
a(3)
|
||||||
a(4)
|
a(4)
|
||||||
server.flush()
|
server.flush()
|
||||||
|
|
@ -278,8 +278,8 @@ describe("historical", function()
|
||||||
local b = Charm.atom(0)
|
local b = Charm.atom(0)
|
||||||
local payloads
|
local payloads
|
||||||
|
|
||||||
server.syncAtomsToClient(client, { a = a })
|
server.subscribeClient(client, { a = a })
|
||||||
server.startSync(function(_, ...)
|
server.connect(function(_, ...)
|
||||||
payloads = ...
|
payloads = ...
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
|
@ -287,7 +287,7 @@ describe("historical", function()
|
||||||
assert(payloads[1].type == "init", "Expected first payload to be init")
|
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.a == 0, "Expected init payload to contain atom 'a'")
|
||||||
|
|
||||||
server.syncAtomsToClient(client, { b = b })
|
server.subscribeClient(client, { b = b })
|
||||||
a(1)
|
a(1)
|
||||||
a(2)
|
a(2)
|
||||||
b(1)
|
b(1)
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ suite.describe("e2e", function()
|
||||||
require("./e2e.spec")
|
require("./e2e.spec")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
suite.describe("flatten", function()
|
suite.describe("utils", function()
|
||||||
require("./flatten.spec")
|
require("./utils.spec")
|
||||||
end)
|
end)
|
||||||
|
|
||||||
suite.describe("patch", function()
|
suite.describe("patch", function()
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,18 @@
|
||||||
local Charm = require("../../Charm")
|
local Charm = require("../../Charm")
|
||||||
local flatten = require("../flatten")
|
|
||||||
local suite = require("../../../../test/suite")
|
local suite = require("../../../../test/suite")
|
||||||
|
local utils = require("../utils")
|
||||||
|
|
||||||
local atom = Charm.atom
|
local atom = Charm.atom
|
||||||
local test = suite.test
|
local test = suite.test
|
||||||
|
|
||||||
test("should flatten atoms", function()
|
test("should flatten atoms", function()
|
||||||
|
local getF, setF = Charm.signal(0)
|
||||||
local input = {
|
local input = {
|
||||||
a = atom(0),
|
a = atom(0),
|
||||||
b = { c = atom(0) },
|
b = { c = atom(0) },
|
||||||
d = { e = { f = atom(0) } },
|
d = { e = { f = utils.signalToAtom(getF, setF) } },
|
||||||
}
|
}
|
||||||
local result = flatten(input)
|
local result = utils.flattenState(input)
|
||||||
|
|
||||||
assert(result.a == input.a, "should flatten the first level")
|
assert(result.a == input.a, "should flatten the first level")
|
||||||
assert(result["b/c"] == input.b.c, "should flatten the second level")
|
assert(result["b/c"] == input.b.c, "should flatten the second level")
|
||||||
|
|
@ -2,46 +2,43 @@ local Charm = require("../Charm")
|
||||||
local patch = require("./patch")
|
local patch = require("./patch")
|
||||||
local types = require("./types")
|
local types = require("./types")
|
||||||
|
|
||||||
|
type Atom<T = any> = Charm.Atom<T>
|
||||||
type SyncPayload = types.SyncPayload
|
type SyncPayload = types.SyncPayload
|
||||||
|
|
||||||
local atomRegister: { [string]: Charm.Atom } = {}
|
local trackedAtoms: { [string]: Atom } = {}
|
||||||
|
|
||||||
--- Registers atoms to be kept in sync with the server. Atoms with matching
|
--- Registers a map of atoms to the state with the corresponding keys on the
|
||||||
--- keys on the server will be synced to these atoms.
|
--- server. When the server sends updates, these atoms will be hydrated with
|
||||||
|
--- the new values.
|
||||||
---
|
---
|
||||||
--- @param atoms An object mapping string keys to atoms to be synced.
|
--- If syncing signals instead of atoms, you can use `signalToAtom` to sync
|
||||||
local function syncAtoms(atoms: { [string]: Charm.Atom })
|
--- signal getters and setters.
|
||||||
|
---
|
||||||
|
--- @param atoms A map of atoms to sync with the server.
|
||||||
|
local function register(atoms: { [string]: Atom })
|
||||||
for key, atom in atoms do
|
for key, atom in atoms do
|
||||||
if atomRegister[key] and atomRegister[key] ~= atom then
|
trackedAtoms[key] = atom
|
||||||
warn("[Charm] Attempted to sync different atoms with the same key:", key)
|
|
||||||
continue
|
|
||||||
end
|
|
||||||
atomRegister[key] = atom
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Unregisters one or more atoms from being kept in sync with the server.
|
--- Unregisters from server state updates for the given keys. The atoms
|
||||||
--- The atoms will retain their current values, but will no longer receive
|
--- will retain their current values, but will no longer receive updates
|
||||||
--- updates from the server.
|
--- from the server.
|
||||||
---
|
local function unregister(...: string)
|
||||||
--- @param keys The keys of the atoms to stop syncing.
|
|
||||||
local function unsyncAtoms(...: string)
|
|
||||||
for index = 1, select("#", ...) do
|
for index = 1, select("#", ...) do
|
||||||
local key = select(index, ...)
|
local key = select(index, ...)
|
||||||
atomRegister[key] = nil
|
trackedAtoms[key] = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Unregisters all atoms from being kept in sync with the server. The
|
--- Unsubscribes from all server state updates.
|
||||||
--- atoms will retain their current values, but will no longer receive
|
|
||||||
--- updates from the server.
|
|
||||||
local function reset()
|
local function reset()
|
||||||
table.clear(atomRegister)
|
table.clear(trackedAtoms)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function applyInitPayload(data: { [string]: any })
|
local function applyInitPayload(data: { [string]: any })
|
||||||
for key, value in data do
|
for key, value in data do
|
||||||
local atom = atomRegister[key]
|
local atom = trackedAtoms[key]
|
||||||
if atom then
|
if atom then
|
||||||
atom(value)
|
atom(value)
|
||||||
end
|
end
|
||||||
|
|
@ -51,23 +48,22 @@ end
|
||||||
local function applyPatchPayload(data: { [string | number]: any })
|
local function applyPatchPayload(data: { [string | number]: any })
|
||||||
local target = {}
|
local target = {}
|
||||||
|
|
||||||
for key, atom in atomRegister do
|
for key, atom in trackedAtoms do
|
||||||
target[key] = atom()
|
target[key] = atom()
|
||||||
end
|
end
|
||||||
|
|
||||||
target = patch.apply(target, data)
|
target = patch.apply(target, data)
|
||||||
|
|
||||||
for key, atom in atomRegister do
|
for key, atom in trackedAtoms do
|
||||||
atom(target[key])
|
atom(target[key])
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Applies one or more payloads received from the server to the synced
|
--- Merges the server's state patches into the client's state, either
|
||||||
--- atoms. Payloads are state patches or initial state updates sent by the
|
--- initializing it with the full state or merging a partial patch.
|
||||||
--- server, and are usually received via a remote event.
|
|
||||||
---
|
---
|
||||||
--- @param payloads The payloads to apply.
|
--- @param payloads The state updates received from the server.
|
||||||
local function applyPayloads(payloads: { SyncPayload })
|
local function hydrate(payloads: { SyncPayload })
|
||||||
for _, payload in payloads do
|
for _, payload in payloads do
|
||||||
if payload.type == "init" then
|
if payload.type == "init" then
|
||||||
Charm.batched(applyInitPayload, payload.data)
|
Charm.batched(applyInitPayload, payload.data)
|
||||||
|
|
@ -78,8 +74,8 @@ local function applyPayloads(payloads: { SyncPayload })
|
||||||
end
|
end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
syncAtoms = syncAtoms,
|
register = register,
|
||||||
unsyncAtoms = unsyncAtoms,
|
unregister = unregister,
|
||||||
reset = reset,
|
reset = reset,
|
||||||
applyPayloads = applyPayloads,
|
hydrate = hydrate,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
type NestedAtomMap = {
|
|
||||||
[string]: NestedAtomMap | () -> any,
|
|
||||||
}
|
|
||||||
|
|
||||||
--- Flattens a nested atom map into a single object with slash-separated keys.
|
|
||||||
--- Useful for recursively collecting atoms returned by modules.
|
|
||||||
---
|
|
||||||
--- @param atoms The nested atom map to flatten.
|
|
||||||
--- @return A flattened atom map.
|
|
||||||
local function flatten(atoms: NestedAtomMap): { [string]: () -> any }
|
|
||||||
local result: { [string]: () -> any } = {}
|
|
||||||
|
|
||||||
local function visit(node: NestedAtomMap, path: string)
|
|
||||||
for key, value in node do
|
|
||||||
local location = if path == "" then key else path .. "/" .. key
|
|
||||||
|
|
||||||
if type(value) == "table" then
|
|
||||||
visit(value, location)
|
|
||||||
else
|
|
||||||
result[location] = value
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
visit(atoms, "")
|
|
||||||
|
|
||||||
return result
|
|
||||||
end
|
|
||||||
|
|
||||||
return flatten
|
|
||||||
130
packages/charm-sync/src/index.d.ts
vendored
130
packages/charm-sync/src/index.d.ts
vendored
|
|
@ -33,29 +33,29 @@ type DataType = DataTypes[keyof DataTypes];
|
||||||
* If a value was removed, it is replaced with `None`. This can be checked
|
* If a value was removed, it is replaced with `None`. This can be checked
|
||||||
* using the `isNone` function.
|
* using the `isNone` function.
|
||||||
*/
|
*/
|
||||||
export type SyncPatch<State, Serialize extends boolean = true> = MaybeNone<State> | State extends
|
export type SyncPatch<State, FixArrays extends boolean = true> = MaybeNone<State> | State extends
|
||||||
| ReadonlyMap<infer K, infer V>
|
| ReadonlyMap<infer K, infer V>
|
||||||
| Map<infer K, infer V>
|
| Map<infer K, infer V>
|
||||||
? ReadonlyMap<K, SyncPatch<V, Serialize> | None>
|
? ReadonlyMap<K, SyncPatch<V, FixArrays> | None>
|
||||||
: State extends Set<infer T> | ReadonlySet<infer T>
|
: State extends Set<infer T> | ReadonlySet<infer T>
|
||||||
? ReadonlyMap<T, true | None>
|
? ReadonlyMap<T, true | None>
|
||||||
: State extends readonly (infer T)[]
|
: State extends readonly (infer T)[]
|
||||||
? Serialize extends true
|
? FixArrays extends true
|
||||||
? ReadonlyMap<string | number, SyncPatch<T, Serialize> | None>
|
? ReadonlyMap<string | number, SyncPatch<T, FixArrays> | None>
|
||||||
: readonly (SyncPatch<T, Serialize> | None | undefined)[]
|
: readonly (SyncPatch<T, FixArrays> | None | undefined)[]
|
||||||
: State extends DataType
|
: State extends DataType
|
||||||
? State
|
? State
|
||||||
: State extends object
|
: State extends object
|
||||||
? { readonly [P in keyof State]?: SyncPatch<State[P], Serialize> }
|
? { readonly [P in keyof State]?: SyncPatch<State[P], FixArrays> }
|
||||||
: State;
|
: 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
|
||||||
* state between the two.
|
* state between the two.
|
||||||
*/
|
*/
|
||||||
export type SyncPayload<Selectors extends SelectorMap = SelectorMap, Serialize extends boolean = true> =
|
export type SyncPayload<Selectors extends SelectorMap = SelectorMap, FixArrays extends boolean = true> =
|
||||||
| { type: "init"; data: StateOfMap<Selectors> }
|
| { type: "init"; data: StateOfMap<Selectors> }
|
||||||
| { type: "patch"; data: SyncPatch<StateOfMap<Selectors>, Serialize> };
|
| { type: "patch"; data: SyncPatch<StateOfMap<Selectors>, FixArrays> };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global configuration options that affect the behavior of Charm Sync.
|
* Global configuration options that affect the behavior of Charm Sync.
|
||||||
|
|
@ -102,38 +102,37 @@ export const config: {
|
||||||
*/
|
*/
|
||||||
export namespace client {
|
export namespace client {
|
||||||
/**
|
/**
|
||||||
* Registers atoms to be kept in sync with the server. Atoms with matching
|
* Registers a map of atoms to the state with the corresponding keys on the
|
||||||
* keys on the server will be synced to these atoms.
|
* server. When the server sends updates, these atoms will be hydrated with
|
||||||
|
* the new values.
|
||||||
*
|
*
|
||||||
* @param atoms An object mapping string keys to atoms to be synced.
|
* If syncing signals instead of atoms, you can use `signalToAtom` to sync
|
||||||
|
* signal getters and setters as atoms.
|
||||||
|
*
|
||||||
|
* @param atoms A map of atoms to sync with the server.
|
||||||
*/
|
*/
|
||||||
export function syncAtoms(atoms: { [key: string]: Atom<any> }): void;
|
export function register(atoms: { [key: string]: Atom<any> }): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unregisters one or more atoms from being kept in sync with the server.
|
* Unregisters from server state updates for the given keys. The atoms
|
||||||
* The atoms will retain their current values, but will no longer receive
|
* will retain their current values, but will no longer receive updates
|
||||||
* updates from the server.
|
* from the server.
|
||||||
*
|
|
||||||
* @param keys The keys of the atoms to stop syncing.
|
|
||||||
*/
|
*/
|
||||||
export function unsyncAtoms(...keys: string[]): void;
|
export function unregister(...keys: string[]): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unregisters all atoms from being kept in sync with the server. The
|
* Unsubscribes from all server state updates.
|
||||||
* atoms will retain their current values, but will no longer receive
|
|
||||||
* updates from the server.
|
|
||||||
*/
|
*/
|
||||||
export function reset(): void;
|
export function reset(): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies one or more payloads received from the server to the synced
|
* Merges the server's state patches into the client's state, either
|
||||||
* atoms. Payloads are state patches or initial state updates sent by the
|
* initializing it with the full state or merging a partial patch.
|
||||||
* server, and are usually received via a remote event.
|
|
||||||
*
|
*
|
||||||
* @param payloads The payloads to apply.
|
* @param payloads The state updates received from the server.
|
||||||
*/
|
*/
|
||||||
export function applyPayloads<Atoms extends { [key: string]: Atom<any> }, Serialize extends boolean = true>(
|
export function hydrate<Atoms extends { [key: string]: Atom<any> }, FixArrays extends boolean = true>(
|
||||||
payloads: SyncPayload<Atoms, Serialize>[],
|
payloads: SyncPayload<Atoms, FixArrays>[],
|
||||||
): void;
|
): void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,61 +141,62 @@ export namespace client {
|
||||||
*/
|
*/
|
||||||
export namespace server {
|
export namespace server {
|
||||||
/**
|
/**
|
||||||
* Registers atoms to be kept in sync with a specific client. Should be
|
* Subscribes a client to the given atoms. When an update occurs, the client
|
||||||
* called when a player joins the game to start syncing atoms to them. Call
|
* will receive a partial state patch to merge into their local state. May be
|
||||||
* `unsyncAtomsFromClient` or `unsyncClient` to stop syncing.
|
* called multiple times to subscribe to additional keys.
|
||||||
*
|
*
|
||||||
* Each atom should be assigned a unique key, allowing the server to re-use
|
* Atoms, signals converted to atoms, and signal getter functions are allowed.
|
||||||
* patches between multiple clients.
|
* Note that the client still requires some way to set the state, so
|
||||||
|
* registering signals on the client should be done through `signalToAtom()`.
|
||||||
*
|
*
|
||||||
* @param client The player to sync atoms to.
|
* @param client The client receiving state updates.
|
||||||
* @param atoms An object mapping string keys to atoms to be synced.
|
* @param atoms A map of atoms to sync with the client.
|
||||||
*/
|
*/
|
||||||
export function syncAtomsToClient(client: Player, atoms: { [key: string]: Atom<any> }): void;
|
export function subscribeClient(client: Player, atoms: { [key: string]: Atom<any> }): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unregisters one or more atoms from being kept in sync with a specific
|
* Unsubscribes a client from receiving all state updates. To only unsubscribe
|
||||||
* client.
|
* from specific keys, use `unsubscribeClientFrom` instead.
|
||||||
*
|
*
|
||||||
* @param client The player to stop syncing atoms to.
|
* @param client The client receiving state updates.
|
||||||
* @param keys The keys of the atoms to stop syncing.
|
|
||||||
*/
|
*/
|
||||||
export function unsyncAtomsFromClient(client: Player, ...keys: string[]): void;
|
export function unsubscribeClient(client: Player): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unregisters all atoms from being kept in sync with a specific client.
|
* Unsubscribes a client from receiving updates for the given keys.
|
||||||
* Should be called when a player leaves the game.
|
|
||||||
*
|
*
|
||||||
* @param client The player to stop syncing atoms to.
|
* @param client The client receiving state updates.
|
||||||
|
* @param keys The keys of the state to unsubscribe from.
|
||||||
*/
|
*/
|
||||||
export function unsyncClient(client: Player): void;
|
export function unsubscribeClientFrom(client: Player, ...keys: string[]): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts automatically syncing registered atoms to clients. Patches will
|
* Uses the callback to send state updates to clients that are subscribed
|
||||||
* be sent to clients whenever the atoms are updated.
|
* to state changes. Starts automatically sending updates at the interval
|
||||||
|
* specified in `config.interval`.
|
||||||
*
|
*
|
||||||
* @param onSync The function to call when patches can be sent to a client.
|
* @param onSync Called when sending patches to a client.
|
||||||
*/
|
*/
|
||||||
export function startSync<Atoms extends { [key: string]: Atom<any> }, Serialize extends boolean = true>(
|
export function connect<Atoms extends { [key: string]: Atom<any> }, FixArrays extends boolean = true>(
|
||||||
onSync: (client: Player, payloads: SyncPayload<Atoms, Serialize>[]) => void,
|
onSync: (client: Player, payloads: SyncPayload<Atoms, FixArrays>[]) => void,
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Stops automatically syncing registered atoms to clients. Patches will
|
* Stops syncing state updates to clients at the automatic interval.
|
||||||
* no longer be sent to clients until `startSync` is called again.
|
* Flushing can still be performed manually via `flush`.
|
||||||
*/
|
*/
|
||||||
export function stopSync(): void;
|
export function disconnect(): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Immediately sends pending updates and initial states to all clients.
|
* Immediately sends pending updates to all clients. Normally, updates are
|
||||||
* Normally, updates are sent at the interval specified in `config.interval`.
|
* sent at the interval specified in `config.interval`, but this function
|
||||||
* Calling this function forces all pending updates to be sent immediately.
|
* can be used to force an immediate sync.
|
||||||
*/
|
*/
|
||||||
export function flush(): void;
|
export function flush(): void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unregisters all atoms from being kept in sync with all clients, and
|
* Unsubscribes all clients, stops tracking all state, and disconnects
|
||||||
* stops automatic syncing.
|
* from the automatic sync interval.
|
||||||
*/
|
*/
|
||||||
export function reset(): void;
|
export function reset(): void;
|
||||||
}
|
}
|
||||||
|
|
@ -204,8 +204,8 @@ export namespace server {
|
||||||
export namespace patch {
|
export namespace patch {
|
||||||
/**
|
/**
|
||||||
* Converts `nil` values to the special `None` symbol. This is useful when
|
* Converts `nil` values to the special `None` symbol. This is useful when
|
||||||
* preparing data for serialization, since `nil` can represent both the absence
|
* preparing data for serialization, since `nil` can represent both the
|
||||||
* of a value and the removal of a value in patches.
|
* absence of a value and the removal of a value in patches.
|
||||||
*/
|
*/
|
||||||
export function nilToNone<T>(value: T | undefined): T | None;
|
export function nilToNone<T>(value: T | undefined): T | None;
|
||||||
|
|
||||||
|
|
@ -217,10 +217,10 @@ export namespace patch {
|
||||||
* @param nextState The next state.
|
* @param nextState The next state.
|
||||||
* @returns A patch that can be applied to the current state.
|
* @returns A patch that can be applied to the current state.
|
||||||
*/
|
*/
|
||||||
export function diff<State, Serialize extends boolean = true>(
|
export function diff<State, FixArrays extends boolean = true>(
|
||||||
currentState: State,
|
currentState: State,
|
||||||
nextState: State,
|
nextState: State,
|
||||||
): SyncPatch<State, Serialize>;
|
): SyncPatch<State, FixArrays>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Applies a patch to a state and returns the resulting value.
|
* Applies a patch to a state and returns the resulting value.
|
||||||
|
|
@ -229,9 +229,9 @@ export namespace patch {
|
||||||
* @param statePatch The patches to apply.
|
* @param statePatch The patches to apply.
|
||||||
* @returns The new state with the patch applied.
|
* @returns The new state with the patch applied.
|
||||||
*/
|
*/
|
||||||
export function apply<State, Serialize extends boolean = true>(
|
export function apply<State, FixArrays extends boolean = true>(
|
||||||
currentState: State,
|
currentState: State,
|
||||||
statePatch: SyncPatch<State, Serialize>,
|
statePatch: SyncPatch<State, FixArrays>,
|
||||||
): State;
|
): State;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,8 +247,6 @@ type AppendPath<Path extends Key | undefined, Name extends Key> = undefined exte
|
||||||
: never
|
: never
|
||||||
: never;
|
: never;
|
||||||
|
|
||||||
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
|
|
||||||
|
|
||||||
type IntersectValues<T> = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>;
|
type IntersectValues<T> = UnionToIntersection<{ readonly [K in keyof T]: T[K] }[keyof T]>;
|
||||||
|
|
||||||
export type FlattenNestedAtoms<
|
export type FlattenNestedAtoms<
|
||||||
|
|
@ -269,7 +267,7 @@ export type FlattenNestedAtoms<
|
||||||
* @param atoms The nested atom map to flatten.
|
* @param atoms The nested atom map to flatten.
|
||||||
* @returns A flattened atom map.
|
* @returns A flattened atom map.
|
||||||
*/
|
*/
|
||||||
export function flatten<Atoms extends NestedAtomMap>(atoms: Atoms): FlattenNestedAtoms<Atoms>;
|
export function flattenAtoms<Atoms extends NestedAtomMap>(atoms: Atoms): FlattenNestedAtoms<Atoms>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks whether a value is `None`. If `true`, the value is scheduled to be
|
* Checks whether a value is `None`. If `true`, the value is scheduled to be
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
local client = require("@self/client")
|
local client = require("@self/client")
|
||||||
local config = require("@self/config")
|
local config = require("@self/config")
|
||||||
local flatten = require("@self/flatten")
|
|
||||||
local patch = require("@self/patch")
|
local patch = require("@self/patch")
|
||||||
local server = require("@self/server")
|
local server = require("@self/server")
|
||||||
local types = require("@self/types")
|
local types = require("@self/types")
|
||||||
|
local utils = require("@self/utils")
|
||||||
|
|
||||||
export type None = types.None
|
export type None = types.None
|
||||||
export type SyncPayload = types.SyncPayload
|
export type SyncPayload = types.SyncPayload
|
||||||
|
|
@ -14,5 +14,6 @@ return {
|
||||||
server = server,
|
server = server,
|
||||||
patch = patch,
|
patch = patch,
|
||||||
isNone = patch.isNone,
|
isNone = patch.isNone,
|
||||||
flatten = flatten,
|
flattenState = utils.flattenState,
|
||||||
|
signalToAtom = utils.signalToAtom,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,85 +7,95 @@ type SyncPayload = types.SyncPayload
|
||||||
|
|
||||||
local globals = Charm.globals
|
local globals = Charm.globals
|
||||||
|
|
||||||
type RegisteredAtom<T = any> = {
|
type SyncStatus = number
|
||||||
callback: () -> T,
|
|
||||||
|
type ClientAtoms = {
|
||||||
|
[string]: SyncStatus,
|
||||||
|
}
|
||||||
|
|
||||||
|
type TrackedState<T = any> = {
|
||||||
|
getter: () -> T,
|
||||||
currentValue: T,
|
currentValue: T,
|
||||||
previousValue: T,
|
previousValue: T,
|
||||||
dispose: () -> (),
|
dispose: () -> (),
|
||||||
}
|
}
|
||||||
|
|
||||||
type SyncStatus = number
|
type StateUpdate = {
|
||||||
|
key: string,
|
||||||
|
currentValue: any,
|
||||||
|
previousValue: any,
|
||||||
|
}
|
||||||
|
|
||||||
local PENDING_INITIAL_STATE: SyncStatus = 0
|
local PENDING_INITIAL_STATE: SyncStatus = 0
|
||||||
local LISTENING_FOR_CHANGES: SyncStatus = 1
|
local LISTENING_FOR_CHANGES: SyncStatus = 1
|
||||||
|
|
||||||
local atomRegister: { [string]: RegisteredAtom } = {}
|
local trackedAtoms: { [string]: TrackedState } = {}
|
||||||
local clientRegister: { [any]: { [string]: SyncStatus } } = {}
|
local globalUpdates: { StateUpdate } = {}
|
||||||
local globalUpdates: { { key: string, currentValue: any, previousValue: any } } = {}
|
local clients: { [any]: ClientAtoms } = {}
|
||||||
|
|
||||||
local connection: RBXScriptConnection?
|
local connection: RBXScriptConnection?
|
||||||
local onSyncCallback: (client: any, payloads: { SyncPayload }) -> ()?
|
local syncCallback: (client: any, payloads: { SyncPayload }) -> ()?
|
||||||
|
|
||||||
local function syncGlobalAtom<T>(key: string, callback: () -> T)
|
local function startTracking<T>(key: string, getter: () -> T)
|
||||||
if atomRegister[key] then
|
if trackedAtoms[key] then
|
||||||
if atomRegister[key].callback ~= callback then
|
-- Ignore duplicates, but warn against name collisions
|
||||||
warn("[Charm] Attempted to sync different atoms with the same key:", key)
|
if trackedAtoms[key].getter ~= getter then
|
||||||
|
warn("[Charm] Attempted to sync different states with the same key:", key)
|
||||||
end
|
end
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local registeredAtom: RegisteredAtom<T> = {
|
local value = getter()
|
||||||
callback = callback,
|
local trackedState = {
|
||||||
currentValue = callback(),
|
getter = getter,
|
||||||
previousValue = callback(),
|
currentValue = value,
|
||||||
dispose = function() end,
|
previousValue = value,
|
||||||
}
|
}
|
||||||
|
|
||||||
registeredAtom.dispose = Charm.effect(function()
|
trackedState.dispose = Charm.effect(function()
|
||||||
local newValue = callback()
|
local newValue = getter()
|
||||||
|
|
||||||
-- When preserving history, we need to keep track of previous values
|
-- When preserving history, we need to keep track of previous values
|
||||||
-- so we can create granular patches for each change.
|
-- so we can create granular patches for each change.
|
||||||
if config.preserveHistory and registeredAtom.currentValue ~= newValue then
|
if config.preserveHistory and trackedState.currentValue ~= newValue then
|
||||||
table.insert(globalUpdates, {
|
table.insert(globalUpdates, {
|
||||||
key = key,
|
key = key,
|
||||||
currentValue = newValue,
|
currentValue = newValue,
|
||||||
previousValue = registeredAtom.currentValue,
|
previousValue = trackedState.previousValue,
|
||||||
})
|
})
|
||||||
end
|
end
|
||||||
|
|
||||||
registeredAtom.currentValue = newValue
|
trackedState.currentValue = newValue
|
||||||
end)
|
end)
|
||||||
|
|
||||||
atomRegister[key] = registeredAtom
|
trackedAtoms[key] = trackedState
|
||||||
end
|
end
|
||||||
|
|
||||||
local function unsyncGlobalAtom(key: string)
|
local function stopTracking(key: string)
|
||||||
local registeredAtom = atomRegister[key]
|
local tracker = trackedAtoms[key]
|
||||||
|
if tracker then
|
||||||
if registeredAtom then
|
tracker.dispose()
|
||||||
registeredAtom.dispose()
|
trackedAtoms[key] = nil
|
||||||
atomRegister[key] = nil
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function unsyncStaleGlobalAtoms()
|
local function purgeUntracked()
|
||||||
local active: { [string]: boolean } = {}
|
local active: { [string]: boolean } = {}
|
||||||
|
|
||||||
for _, clientAtoms in clientRegister do
|
for _, clientAtoms in clients do
|
||||||
for key in clientAtoms do
|
for key in clientAtoms do
|
||||||
active[key] = true
|
active[key] = true
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
for key in atomRegister do
|
for key in trackedAtoms do
|
||||||
if not active[key] then
|
if not active[key] then
|
||||||
unsyncGlobalAtom(key)
|
stopTracking(key)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
local function createGlobalPatchHistory(): { any }
|
local function diffGlobalUpdateBuffer(): { any }
|
||||||
local patchCount = #globalUpdates
|
local patchCount = #globalUpdates
|
||||||
local patches = table.create(patchCount)
|
local patches = table.create(patchCount)
|
||||||
|
|
||||||
|
|
@ -96,10 +106,10 @@ local function createGlobalPatchHistory(): { any }
|
||||||
return patches
|
return patches
|
||||||
end
|
end
|
||||||
|
|
||||||
local function createPayloadHistoryForClient(client: any, patches: { any }): { SyncPayload }
|
local function selectFromGlobalPatchList(client: any, globalPatches: { any }): { SyncPayload }
|
||||||
local syncedAtoms = clientRegister[client]
|
local clientAtoms = clients[client]
|
||||||
|
|
||||||
if not syncedAtoms then
|
if not clientAtoms then
|
||||||
return {}
|
return {}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -108,14 +118,14 @@ local function createPayloadHistoryForClient(client: any, patches: { any }): { S
|
||||||
-- Iterate over all global updates and see if the client is interested in
|
-- Iterate over all global updates and see if the client is interested in
|
||||||
-- any of them. If so, we create a patch payload for the update.
|
-- any of them. If so, we create a patch payload for the update.
|
||||||
for index, update in globalUpdates do
|
for index, update in globalUpdates do
|
||||||
local patchData = patches[index]
|
local patchData = globalPatches[index]
|
||||||
|
|
||||||
if not patchData then
|
if not patchData then
|
||||||
continue
|
continue
|
||||||
end
|
end
|
||||||
|
|
||||||
local key = update.key
|
local key = update.key
|
||||||
local status = syncedAtoms[key]
|
local status = clientAtoms[key]
|
||||||
|
|
||||||
if status == LISTENING_FOR_CHANGES then
|
if status == LISTENING_FOR_CHANGES then
|
||||||
table.insert(payloads, {
|
table.insert(payloads, {
|
||||||
|
|
@ -127,179 +137,180 @@ local function createPayloadHistoryForClient(client: any, patches: { any }): { S
|
||||||
type = "init",
|
type = "init",
|
||||||
data = { [key] = update.currentValue },
|
data = { [key] = update.currentValue },
|
||||||
})
|
})
|
||||||
syncedAtoms[key] = LISTENING_FOR_CHANGES
|
clientAtoms[key] = LISTENING_FOR_CHANGES
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
-- If a pending atom was never updated, we still need to send its initial
|
-- If a pending atom was never updated, we still need to send its initial
|
||||||
-- state. This can happen if the atom was registered after its last change.
|
-- state. This can happen if the atom was registered after its last change.
|
||||||
local initPayload: { [string]: any } = {}
|
local initData: { [string]: any } = {}
|
||||||
|
|
||||||
for key, status in syncedAtoms do
|
for key, status in clientAtoms do
|
||||||
local registeredAtom = atomRegister[key]
|
local trackedState = trackedAtoms[key]
|
||||||
|
|
||||||
if status == PENDING_INITIAL_STATE and registeredAtom then
|
if status == PENDING_INITIAL_STATE and trackedState then
|
||||||
initPayload[key] = registeredAtom.currentValue
|
initData[key] = trackedState.currentValue
|
||||||
syncedAtoms[key] = LISTENING_FOR_CHANGES
|
clientAtoms[key] = LISTENING_FOR_CHANGES
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if next(initPayload) ~= nil then
|
if next(initData) ~= nil then
|
||||||
table.insert(payloads, 1, { type = "init" :: "init", data = initPayload })
|
table.insert(payloads, 1, { type = "init" :: "init", data = initData })
|
||||||
end
|
end
|
||||||
|
|
||||||
return payloads
|
return payloads
|
||||||
end
|
end
|
||||||
|
|
||||||
local function createGlobalPatch(): any
|
local function diffGlobalState(): any
|
||||||
local current: { [string]: any } = {}
|
local currentGlobalState: { [string]: any } = {}
|
||||||
local previous: { [string]: any } = {}
|
local previousGlobalState: { [string]: any } = {}
|
||||||
|
|
||||||
for key, registeredAtom in atomRegister do
|
for key, tracker in trackedAtoms do
|
||||||
current[key] = registeredAtom.currentValue
|
currentGlobalState[key] = tracker.currentValue
|
||||||
previous[key] = registeredAtom.previousValue
|
previousGlobalState[key] = tracker.previousValue
|
||||||
registeredAtom.previousValue = registeredAtom.currentValue
|
tracker.previousValue = tracker.currentValue
|
||||||
end
|
end
|
||||||
|
|
||||||
return patch.diff(previous, current)
|
return patch.diff(previousGlobalState, currentGlobalState)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function createPayloadForClient(client: any, patchData: any): { SyncPayload }
|
local function selectFromGlobalPatch(client: any, globalPatch: any): { SyncPayload }
|
||||||
local syncedAtoms = clientRegister[client]
|
local clientAtoms = clients[client]
|
||||||
local payloads: { SyncPayload } = {}
|
|
||||||
|
|
||||||
if not syncedAtoms then
|
if not clientAtoms then
|
||||||
return payloads
|
return {}
|
||||||
end
|
end
|
||||||
|
|
||||||
local initPayload: { [string]: any } = {}
|
local payloads: { SyncPayload } = {}
|
||||||
local patchPayload: { [string]: any } = {}
|
local initData: { [string]: any } = {}
|
||||||
|
local patchData: { [string]: any } = {}
|
||||||
|
|
||||||
-- Create patches for initialized atoms that changed, and add atoms that
|
-- Create patches for initialized atoms that changed, and add atoms that
|
||||||
-- are still pending initial state to the init payload.
|
-- are still pending initial state to the init payload.
|
||||||
for key, status in syncedAtoms do
|
for key, status in clientAtoms do
|
||||||
local registeredAtom = atomRegister[key]
|
local trackedState = trackedAtoms[key]
|
||||||
|
|
||||||
if status == LISTENING_FOR_CHANGES then
|
if status == LISTENING_FOR_CHANGES then
|
||||||
patchPayload[key] = patchData[key]
|
patchData[key] = globalPatch[key]
|
||||||
elseif status == PENDING_INITIAL_STATE then
|
elseif status == PENDING_INITIAL_STATE then
|
||||||
initPayload[key] = registeredAtom.currentValue
|
initData[key] = trackedState.currentValue
|
||||||
syncedAtoms[key] = LISTENING_FOR_CHANGES
|
clientAtoms[key] = LISTENING_FOR_CHANGES
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
if next(initPayload) ~= nil then
|
if next(initData) ~= nil then
|
||||||
table.insert(payloads, { type = "init", data = initPayload })
|
table.insert(payloads, { type = "init", data = initData })
|
||||||
end
|
end
|
||||||
|
|
||||||
if next(patchPayload) ~= nil then
|
if next(patchData) ~= nil then
|
||||||
table.insert(payloads, { type = "patch", data = patchPayload })
|
table.insert(payloads, { type = "patch", data = patchData })
|
||||||
end
|
end
|
||||||
|
|
||||||
return payloads
|
return payloads
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Registers atoms to be kept in sync with a specific client. Should be
|
--- Subscribes a client to the given atoms. When an update occurs, the client
|
||||||
--- called when a player joins the game to start syncing atoms to them. Call
|
--- will receive a partial state patch to merge into their local state. May be
|
||||||
--- `unsyncAtomsFromClient` or `unsyncClient` to stop syncing.
|
--- called multiple times to subscribe to additional keys.
|
||||||
---
|
---
|
||||||
--- Each atom should be assigned a unique key, allowing the server to re-use
|
--- Atoms, signals converted to atoms, and signal getter functions are allowed.
|
||||||
--- patches between multiple clients.
|
--- Note that the client still requires some way to set the state, so
|
||||||
|
--- registering signals on the client should be done through `signalToAtom()`.
|
||||||
---
|
---
|
||||||
--- @param client The player to sync atoms to.
|
--- @param client The client receiving state updates.
|
||||||
--- @param atoms An object mapping string keys to atoms to be synced.
|
--- @param atoms A map of atoms to sync with the client.
|
||||||
local function syncAtomsToClient(client: any, atoms: { [string]: () -> any })
|
local function subscribeClient(client: any, atoms: { [string]: () -> any })
|
||||||
if not clientRegister[client] then
|
if not clients[client] then
|
||||||
clientRegister[client] = {}
|
clients[client] = {}
|
||||||
end
|
end
|
||||||
|
|
||||||
local syncedAtoms = clientRegister[client]
|
local clientAtoms = clients[client]
|
||||||
|
|
||||||
for key, atom in atoms do
|
for key, state in atoms do
|
||||||
syncGlobalAtom(key, atom)
|
startTracking(key, if type(state) == "function" then state else state.get)
|
||||||
|
|
||||||
if not syncedAtoms[key] then
|
if not clientAtoms[key] then
|
||||||
-- Mark the atom as pending initial state so we send its full value
|
-- Mark the atom as pending initial state so we send its full value
|
||||||
-- the next time we sync.
|
-- the next time we sync.
|
||||||
syncedAtoms[key] = PENDING_INITIAL_STATE
|
clientAtoms[key] = PENDING_INITIAL_STATE
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Unregisters all atoms from being kept in sync with a specific client.
|
--- Unsubscribes a client from receiving all state updates. To only unsubscribe
|
||||||
--- Should be called when a player leaves the game.
|
--- from specific keys, use `unsubscribeClientFrom` instead.
|
||||||
---
|
---
|
||||||
--- @param client The player to stop syncing atoms to.
|
--- @param client The client receiving state updates.
|
||||||
local function unsyncClient(client: any)
|
local function unsubscribeClient(client: any)
|
||||||
clientRegister[client] = nil
|
clients[client] = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Unregisters one or more atoms from being kept in sync with a specific
|
--- Unsubscribes a client from receiving updates for the given keys.
|
||||||
--- client.
|
|
||||||
---
|
---
|
||||||
--- @param client The player to stop syncing atoms to.
|
--- @param client The client receiving state updates.
|
||||||
--- @param keys The keys of the atoms to stop syncing.
|
--- @param keys The keys of the state to unsubscribe from.
|
||||||
local function unsyncAtomsFromClient(client: any, ...: string)
|
local function unsubscribeClientFrom(client: any, ...: string)
|
||||||
local syncedAtoms = clientRegister[client]
|
local clientAtoms = clients[client]
|
||||||
|
|
||||||
if not syncedAtoms then
|
if not clientAtoms then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
for index = 1, select("#", ...) do
|
for index = 1, select("#", ...) do
|
||||||
local key = select(index, ...)
|
local key = select(index, ...)
|
||||||
syncedAtoms[key] = nil
|
clientAtoms[key] = nil
|
||||||
end
|
end
|
||||||
|
|
||||||
if next(syncedAtoms) == nil then
|
if next(clientAtoms) == nil then
|
||||||
clientRegister[client] = nil
|
clients[client] = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Immediately sends pending updates and initial states to all clients.
|
--- Immediately sends pending updates to all clients. Normally, updates are
|
||||||
--- Normally, updates are sent at the interval specified in `config.interval`.
|
--- sent at the interval specified in `config.interval`, but this function
|
||||||
--- Calling this function forces all pending updates to be sent immediately.
|
--- can be used to force an immediate sync.
|
||||||
local function flush()
|
local function flush()
|
||||||
if not onSyncCallback then
|
if not syncCallback then
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
|
||||||
local patches = if config.preserveHistory then createGlobalPatchHistory() else createGlobalPatch()
|
local globalPatch = if config.preserveHistory then diffGlobalUpdateBuffer() else diffGlobalState()
|
||||||
|
|
||||||
-- While payload objects are technically mutable as they are unique to
|
-- While payload objects are technically mutable as they are unique to
|
||||||
-- each client, the data inside them is not safe to mutate.
|
-- each client, the data inside them is not safe to mutate.
|
||||||
if globals.frozen then
|
if globals.frozen then
|
||||||
Charm.deepFreeze(patches)
|
Charm.deepFreeze(globalPatch)
|
||||||
end
|
end
|
||||||
|
|
||||||
for client in clientRegister do
|
for client in clients do
|
||||||
local payloads: { SyncPayload } = if config.preserveHistory
|
local payloads: { SyncPayload } = if config.preserveHistory
|
||||||
then createPayloadHistoryForClient(client, patches)
|
then selectFromGlobalPatchList(client, globalPatch)
|
||||||
else createPayloadForClient(client, patches)
|
else selectFromGlobalPatch(client, globalPatch)
|
||||||
|
|
||||||
if payloads[1] then
|
if payloads[1] then
|
||||||
if task then
|
if task then
|
||||||
task.spawn(onSyncCallback, client, payloads)
|
task.spawn(syncCallback, client, payloads)
|
||||||
else
|
else
|
||||||
onSyncCallback(client, payloads)
|
syncCallback(client, payloads)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
unsyncStaleGlobalAtoms()
|
purgeUntracked()
|
||||||
table.clear(globalUpdates)
|
table.clear(globalUpdates)
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Starts automatically syncing registered atoms to clients. Patches will
|
--- Uses the callback to send state updates to clients that are subscribed
|
||||||
--- be sent to clients whenever the atoms are updated.
|
--- to state changes. Starts automatically sending updates at the interval
|
||||||
|
--- specified in `config.interval`.
|
||||||
---
|
---
|
||||||
--- @param onSync The function to call when patches can be sent to a client.
|
--- @param onSync Called when sending patches to a client.
|
||||||
local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ())
|
local function connect(onSync: (client: any, payloads: { SyncPayload }) -> ())
|
||||||
if connection then
|
if connection then
|
||||||
connection:Disconnect()
|
connection:Disconnect()
|
||||||
end
|
end
|
||||||
|
|
||||||
onSyncCallback = onSync
|
syncCallback = onSync
|
||||||
|
|
||||||
if not game then
|
if not game then
|
||||||
return
|
return
|
||||||
|
|
@ -323,30 +334,30 @@ local function startSync(onSync: (client: any, payloads: { SyncPayload }) -> ())
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Stops automatically syncing registered atoms to clients. Patches will
|
--- Stops syncing state updates to clients at the automatic interval.
|
||||||
--- no longer be sent to clients until `startSync` is called again.
|
--- Flushing can still be performed manually via `flush`.
|
||||||
local function stopSync()
|
local function disconnect()
|
||||||
if connection then
|
if connection then
|
||||||
connection:Disconnect()
|
connection:Disconnect()
|
||||||
connection = nil
|
connection = nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Unregisters all atoms from being kept in sync with all clients, and
|
--- Unsubscribes all clients, stops tracking all state, and disconnects
|
||||||
--- stops automatic syncing.
|
--- from the automatic sync interval.
|
||||||
local function reset()
|
local function reset()
|
||||||
table.clear(atomRegister)
|
table.clear(trackedAtoms)
|
||||||
table.clear(clientRegister)
|
table.clear(clients)
|
||||||
table.clear(globalUpdates)
|
table.clear(globalUpdates)
|
||||||
stopSync()
|
disconnect()
|
||||||
end
|
end
|
||||||
|
|
||||||
return {
|
return {
|
||||||
syncAtomsToClient = syncAtomsToClient,
|
subscribeClient = subscribeClient,
|
||||||
unsyncClient = unsyncClient,
|
unsubscribeClient = unsubscribeClient,
|
||||||
unsyncAtomsFromClient = unsyncAtomsFromClient,
|
unsubscribeClientFrom = unsubscribeClientFrom,
|
||||||
startSync = startSync,
|
connect = connect,
|
||||||
stopSync = stopSync,
|
disconnect = disconnect,
|
||||||
flush = flush,
|
flush = flush,
|
||||||
reset = reset,
|
reset = reset,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
packages/charm-sync/src/utils.luau
Normal file
52
packages/charm-sync/src/utils.luau
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
local Charm = require("../Charm")
|
||||||
|
|
||||||
|
type Atom<T> = Charm.Atom<T>
|
||||||
|
|
||||||
|
type NestedState = {
|
||||||
|
[string]: NestedState | () -> any,
|
||||||
|
}
|
||||||
|
|
||||||
|
type FlattenedState = {
|
||||||
|
[string]: () -> any,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function isAtomOrSignal(value: any): boolean
|
||||||
|
return type(value) ~= "table" or (type(value.get) == "function" and type(value.set) == "function")
|
||||||
|
end
|
||||||
|
|
||||||
|
--- Flattens a nested map of atoms or signals into a single object with
|
||||||
|
--- slash-separated keys. Useful for recursively collecting atoms returned by
|
||||||
|
--- modules.
|
||||||
|
---
|
||||||
|
--- @param states A nested table of atoms or signals.
|
||||||
|
--- @return A flattened map of atoms or signals.
|
||||||
|
local function flattenState(states: NestedState): FlattenedState
|
||||||
|
local result: FlattenedState = {}
|
||||||
|
|
||||||
|
local function visit(node: NestedState, path: string)
|
||||||
|
for key, value in node do
|
||||||
|
local location = if path == "" then key else path .. "/" .. key
|
||||||
|
|
||||||
|
if isAtomOrSignal(value) then
|
||||||
|
result[location] = value :: () -> any
|
||||||
|
else
|
||||||
|
visit(value :: NestedState, location)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
visit(states, "")
|
||||||
|
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local function signalToAtom<T>(getter: () -> T, setter: (value: ((T) -> T) | T) -> T): Atom<T>
|
||||||
|
return function(...)
|
||||||
|
return if select("#", ...) == 0 then getter() else setter((...))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
return {
|
||||||
|
flattenState = flattenState,
|
||||||
|
signalToAtom = signalToAtom,
|
||||||
|
}
|
||||||
|
|
@ -312,7 +312,7 @@ end
|
||||||
--- @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-equals
|
--- @see https://github.com/littensy/charm?tab=readme-ov-file#atomstate-equals
|
||||||
local function atom<T>(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T
|
local function atom<T>(initialValue: T, equals: ((current: T, incoming: T) -> boolean)?): (...((T) -> T) | T) -> T
|
||||||
local getter, setter = signal(initialValue, equals)
|
local getter, setter = signal(initialValue, equals)
|
||||||
return function(...: ((T) -> T) | T): T
|
return function(...)
|
||||||
return if select("#", ...) == 0 then getter() else setter((...))
|
return if select("#", ...) == 0 then getter() else setter((...))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -321,9 +321,6 @@ end
|
||||||
--- The returned atom is read-only, and its value is automatically updated when
|
--- The returned atom is read-only, and its value is automatically updated when
|
||||||
--- any atoms accessed from within the getter function change.
|
--- any atoms accessed from within the getter function change.
|
||||||
---
|
---
|
||||||
--- If an initial value is provided, the getter will receive this value as its
|
|
||||||
--- first argument on the initial run, instead of `undefined`.
|
|
||||||
---
|
|
||||||
--- @example
|
--- @example
|
||||||
--- ```ts
|
--- ```ts
|
||||||
--- const count = atom(1);
|
--- const count = atom(1);
|
||||||
|
|
@ -332,8 +329,7 @@ end
|
||||||
--- ```
|
--- ```
|
||||||
---
|
---
|
||||||
--- @param getter A function that produces the next value.
|
--- @param getter A function that produces the next value.
|
||||||
--- @param value An optional initial value for the selector.
|
--- @see https://github.com/littensy/charm?tab=readme-ov-file#computedgetter
|
||||||
--- @see https://github.com/littensy/charm?tab=readme-ov-file#computedcallback-options
|
|
||||||
local function computed<T>(getter: (previousValue: T?) -> T): () -> T
|
local function computed<T>(getter: (previousValue: T?) -> T): () -> T
|
||||||
return computedOper({
|
return computedOper({
|
||||||
flags = bit32.bor(MUTABLE, DIRTY),
|
flags = bit32.bor(MUTABLE, DIRTY),
|
||||||
|
|
@ -432,6 +428,7 @@ end
|
||||||
--- @param callback The callback function.
|
--- @param callback The callback function.
|
||||||
--- @param args Arguments to pass to the callback.
|
--- @param args Arguments to pass to the callback.
|
||||||
--- @return The return value of the callback.
|
--- @return The return value of the callback.
|
||||||
|
--- @see https://github.com/littensy/charm?tab=readme-ov-file#untrackedcallback
|
||||||
local function untracked<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
|
local function untracked<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
|
||||||
local prevSub = setActiveSub(nil)
|
local prevSub = setActiveSub(nil)
|
||||||
local results: { any } = { pcall(callback, ...) }
|
local results: { any } = { pcall(callback, ...) }
|
||||||
|
|
@ -450,6 +447,7 @@ end
|
||||||
--- @param callback The callback function.
|
--- @param callback The callback function.
|
||||||
--- @param args Arguments to pass to the callback.
|
--- @param args Arguments to pass to the callback.
|
||||||
--- @return The return value of the callback.
|
--- @return The return value of the callback.
|
||||||
|
--- @see https://github.com/littensy/charm?tab=readme-ov-file#peekcallback
|
||||||
local function peek<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
|
local function peek<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
|
||||||
if
|
if
|
||||||
activeSub
|
activeSub
|
||||||
|
|
@ -474,6 +472,7 @@ end
|
||||||
--- @param callback A function that performs multiple atom updates.
|
--- @param callback A function that performs multiple atom updates.
|
||||||
--- @param args Arguments to pass to the callback.
|
--- @param args Arguments to pass to the callback.
|
||||||
--- @return The return value of the callback.
|
--- @return The return value of the callback.
|
||||||
|
--- @see https://github.com/littensy/charm?tab=readme-ov-file#batchedcallback
|
||||||
local function batched<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
|
local function batched<Args..., Results...>(callback: (Args...) -> Results..., ...: Args...): Results...
|
||||||
startBatch()
|
startBatch()
|
||||||
local results: { any } = { (if globals.strict then noYield else pcall)(callback, ...) }
|
local results: { any } = { (if globals.strict then noYield else pcall)(callback, ...) }
|
||||||
|
|
|
||||||
2
packages/charm/src/index.d.ts
vendored
2
packages/charm/src/index.d.ts
vendored
|
|
@ -83,11 +83,9 @@ export function atom<T>(): Atom<T | undefined>;
|
||||||
* ```
|
* ```
|
||||||
*
|
*
|
||||||
* @param getter A function that produces the next value.
|
* @param getter A function that produces the next value.
|
||||||
* @param value An optional initial value for the selector.
|
|
||||||
* @see https://github.com/littensy/charm?tab=readme-ov-file#computedcallback-options
|
* @see https://github.com/littensy/charm?tab=readme-ov-file#computedcallback-options
|
||||||
*/
|
*/
|
||||||
export function computed<T>(getter: (previousValue?: T) => T): () => T;
|
export function computed<T>(getter: (previousValue?: T) => T): () => T;
|
||||||
export function computed<T>(getter: (previousValue: T) => T, value: T): () => T;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates an effect that runs a callback in response to atom state changes.
|
* Creates an effect that runs a callback in response to atom state changes.
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue