"Watch" API: Manage Events & Observe Tables

Watch API should now properly return the player as the first argument to any callbacks that are invoked as a result of the Client calling FireAcross (or more specifically, when the Server calls FireOnce on itself with the Client’s arguments passed in).

I still need to add the unsubscribe stuff, and I think I want to support a wildcard such that you can Watch all properties of a table and they all call the same handler. That way, you could do the following to fire an event to each player individually when the structure is a list of player names (for example).

contrived example below - wildcards do not currently work!

-- Server
local scores = Watch('Scores', {bill=5,bob=10,jim=20})
local watchAll = scores:On('*') -- basically array of property names {bill,bob,jim}
watchAll:Do(function (v, k, s, c) -- call Do() on each name giving same generic handler
    scores:FireAcross(k, v, k, s, c) -- ugly, I know.. but good enough for now.
end)

scores.bill = 40 would be like calling Noun(‘scores’):FireOnce(‘bill’, value, key, rw, cache)
Note: This leads to further setup requirements as Table access always call FireOnce
skipping that setup here for brevity and while I figure out a better way to do it.

--Client
-- listen for event named Player.Name from the Scores Object
local player = game.Players.LocalPlayer.Name
Watch('Scores'):On(player):Do(function (v, k)  print(k..' your score is '..v) end)
1 Like

If you don’t mind me asking, how do you track table changes? I know how to track new keys with metatables but not existing keys.

Not sure if this is how OP is doing it, but you use the public table as a proxy for a private table that is actually manipulated. Public table is always empty, so it always invokes _index/_newindex.

Yeah, I thought about going the proxy route however I have multiple nested tables which I need to track the changes for. Was hoping there was an easier way.

Easy way? Not really, but I’ve posted some code for something similar before:

local Data = {
	A = {
		B = {
			C = 123;
		}
	};
	GG = 456;
	List = {1,2,3};
}

local function resolve(keys)
	local tab = Data
	for i=1,#keys do
		tab = tab[keys[i]]
		if not tab then return end
	end return tab
end

local proxyMeta = {}
-- Makes it that proxyMeta({"List"})() returns {1,2,3}
proxyMeta.__call = resolve
-- Return a proxy for tables, otherwise the real value
function proxyMeta:__index(key)
	local res = self()[key]
	if type(res) ~= "table" then return res end
	res = setmetatable({},proxyMeta)
	for i=1,#self do res[i] = self[i] end
	res[#res+1] = key return res
end
-- Set the value in Data, then replicate
function proxyMeta:__newindex(key,val)
	self()[key] = val
	local path = table.concat(self,".")
	path = path.."."..key
	print("Data."..path,"got set to",val)
	-- Replicate()
end

local SharedData = setmetatable({},proxyMeta)
print(SharedData.A.B) --> {"A","B"} with proxyMeta
print(SharedData.A.B.C) --> 123
print(SharedData.GG) --> 456
-- Now it gets a bit annoying
print(SharedData.List) --> {"List"} with proxyMeta
-- So I added __call that returns the value anyway
print(SharedData.List()) --> {1,2,3}

-- Shared.A returns {"A"} with proxyMeta
-- __newindex will set Data.A.B to the value
SharedData.A.B = "Oh"
--> SharedData.A.B got set to Oh

SharedData.AnotherTabe = {A=123,B={123}}
--> SharedData..AnotherTable got set to table: A8ED93
-- (the double dot is just because I'm too lazy to
-- check for an empty self in __newindex)
print(SharedData.AnotherTable.A) --> 123
print(SharedData.AnotherTable.B) --> proxy
print(SharedData.AnotherTable.B()) --> {123}
print(SharedData.AnotherTable.B[1]) --> 123

(originally posted it here)

Maybe @HuotChu could implement something like this. Only “issue” is that if you do e.g. SharedData.SomeTable, it’ll return a proxy for it, which works fine as read-only and most writing, but not if you use the # length operator or table.something() which you’ll then have to account for (basically anthing where you alter the table that doesn’t trigger __newindex)

2 Likes

EchoReaper and einsteinK are both correct, I store the original table in the internal “Objects” table and handle reading and writing to it through a proxy. In fact, I don’t even metatable the original table… when it looks like you are calling API methods on it, they are actually called from a ‘Noun’ object named the same as the original table was named. I did this to prevent overwriting any existing metatable the original table may have, plus I don’t like augmenting the source table in general.

Currently, to wrap inner tables, you just have to watch them individually. I’m working out a better solution for this now. I had not considered einstein’s point about Length, etc, but I’m pretty sure I can push those method calls to the Noun object and give it the methods to run against the original table. That would allow me to keep the proxy clean.

Current method to watch nested tables:

    local t = {
        foo = 'bar',
        inner = {
            fooz = 'baz'
        }
    }

local innerWatch = Watch('Inner', t.inner)
local outerWatch = Watch('Outer', t)
-- innerWatch:On('fooz'):Do(func)
-- outerWatch:On('foo'):Do(func)

I will post in this topic when I have it worked out to be more convenient to use.
Thanks for the use cases, btw. These are the kinds of comments that really help me know what the goals of the API should be :slight_smile:

1 Like

I got it! :smiley:
This code successfully proxies all sub-tables, plus the parent table. It also mixes in a subset of my Array4Lua module to provide Length and a few other functions (Every, Filter, Find, ForEach, Length, Some, Sort). it does all this without modifying the original table (if the source table already had a metatable, it is left intact as well). I’ll be working up a new flavor of the Watch API based on this new code…

local Tables = require(script.Parent:WaitForChild('Tables'))  -- subset of Array4Lua

local proxy = function (src, proxy)
	local meta = {}
	meta.__index = function (t, k)
		local r = meta[k] or src[k]
		print( 'Get '..k..' == '..tostring(r) )  -- throw Get event
		return r
	end
	meta.__newindex = function (t, k, v)
		print( 'Set '..k..' to '..tostring(v) )  -- throw Set event
		src[k] = v
	end
	for n, f in pairs(Tables) do
		meta[n] = function (...)
			return f(src, unpack({...}, 2))
		end
	end
	proxy = proxy or {}
	return setmetatable(proxy, meta)
end

function watch (o, node, parentNode)
	local target = {}
    if type(o) ~= 'table' then
		if node and parentNode then
			target[parentNode] = proxy(node, target)
		end
		return o
	end
    for k, v in pairs(o) do
        if type(v) == 'table' then
            target[k] = watch(v, o, k)
        end
    end
    return proxy(o, target)
end

The only problem is that there is a programming decision to make. This structure only knows the name of the property that changed by the time the handler is called, therefor any event handler watching “foo” would fire for every “foo” named property of the table tree structure. Is this desirable or should I track the full path and assign the handlers to the paths?

If I want to watch when a player’s kills increase and I have a nested array of player data, this is undesirable.

I’d say it’s better to at least be able to get the full path. The developer can always choose to use which field to use.

In my code, I had it that the proxy table was something like {field1,field2,...} where doing proxy[k] would return a copy of the proxy with k inserted into the table. A side effect of this, which might both be good and bad:

local Data = { A = { B = { C = 123 } } }

-- Assuming your proxy has set its Set and Get event to get/set from/to Data
local proxy = Proxy(Data)

local proxyB = proxy.A.B
print(proxyB.C) --> 123

proxy.A = { B = { C = 456 } }
print(proxyB.C) --> your version: 123 ('src' is cached, proxy only remembers the source)
print(proxyB.C) --> my version: 456 (directly uses Data, proxy only remembers previous keys (and Data))

proxyB.C = 1 --> your version: won't change Data (since it uses the internal src table)
proxyB.C = 2 --> my version: will change Data, since it knows Data and uses the keys it remembered

also, what are you doing with target[parentNode], if you then return and never use target?

Remember, Target is the proxy which is normally kept empty. I’m not copying the table into it. In fact, if the node I’m on is not a table, I basically skip it and move on. What the code is doing is storing a reference to any nodes that contain a table as keys in the proxy and the value of each key is the sub-table’s proxy.

Target is used, due to the first closure created the first time the function runs, it becomes the master proxy table. Then, on each subsequent iteration, Target is a new table, but the original Target (proxy) is still in the closure tracking the sub-Targets being updated in the loop. Just before the function exits, the parent Target proxy table has it’s tracking hooked up and the entire master proxy is returned with all it’s child proxies linked inside.

Also, your test results are close but the correct results are:

local Data = { A = { B = { C = 123 } } }

local proxy = watch(Data)
local proxyB = proxy.A.B
print(proxyB.C) --> 123

proxy.A = { B = { C = 456 } }
print(proxyB.C) --> HC version: 123 ('src' is cached, proxy only remembers the source) << this is a problem
print(proxyB.C) --> EK version: 456 (directly uses Data, proxy only remembers previous keys (and Data))

proxyB.C = 456
print(proxyB.C) --> HC version: 456 (works, but should also work as above)

proxyB.C = 1 --> HC version: will change Data!

print(proxyB.C) --> prints 1

print(proxy:toString()) --> A=B=C=1

I’m not sure I follow you. If you have a table such as:

{
    Polymorphic = {
        kills = 24,
        xp = 50000
    },
    HuotChu = {
        kills = 7,
        xp = 14000
    }
}

And you Watch that table for changes to ‘kills’, would you not want the same handler to fire for each ‘kills’ property change, regardless of player (if the player/parent-node is also passed to the callback)? If you have to track paths, then instead of watching one ‘kills’ for this table, you would have to expressly name watching each one with a fields list: Watch:Fields({Polymorphic.kills, HuotChu.kills})

I prefer the first way, but I would like to understand the various use cases, so that I don’t program on a bias towards my own needs.

EDIT Perhaps a better approach would be similar to the way Dojo.Observe works…

…an object store wrapper that adds support for notification of data changes to query result sets. The query result sets returned from an Observable store will include an observe function that can be used to monitor for changes.

In this framework, you query the data for things and you watch the query, not the table. That way, it only listens to changes on the data nodes that were used as part of the query against the data.

^citation: dojo/store/Observable — The Dojo Toolkit - Reference Guide