diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8c9a0177..d6fa23a9 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -1,7 +1,7 @@ # ModulesOnRails Copilot Instructions ## Goal -This repository contains multiple Roblox Wally modules. Favor consistency inside the package you are editing over consistency with the rest of the repository. +This repository contains multiple Roblox Wally modules. Favor consistency inside the package you are editing over consistency with the rest of the repository. We use Luau, not Lua. ## Priority Order 1. Follow the local style of the current package first. @@ -21,14 +21,11 @@ If a package is pure Luau and has multiple modules, do not use `init.luau` as th Publishing will generate an `init.luau` re-export automatically. Non-pure-Luau packages may use `init.luau` directly. -## Setup For Development -Use `npm run setup ` when deeper local package context is needed. This installs dependencies and arranges them similarly to live usage, improving autocomplete, linting, and diagnostics in VS Code. - ## Luau Style Rules - Use PascalCase for class names, table fields, and method names. - Use camelCase for variable names. - Use SCREAMING_SNAKE_CASE for constants. -- Prefix private fields and methods with `_`. +- Prefix private fields and methods of class objects with `_`. - Add explicit types for non-inferred parameters and function signatures. - For Luau classes, define both a public type and an internal type. - Internal type should extend public type with private fields and methods. @@ -36,8 +33,9 @@ Use `npm run setup ` when deeper local package context is needed. - Call those methods with colon syntax. - Keep existing comments and debug logic unless removal is explicitly requested or the content is now outdated. - Methods/functions that yield or could potentially yield should be either suffixed with `Async` or return as a Promise to prevent unexpected behavior. -- Avoid magic numbers. That is, numbers with no obvious underlying meaning. You can attribute meaning to a number by assigning it to a variable or constant with a descriptive name, or by writing a comment explaining what the number's purpose is. -- Packages should always have relative paths for their requires. Never require another module with an absolute path. +- Avoid magic numbers. That is, numbers or valueswith no obvious underlying meaning. You can attribute meaning to a number by assigning it to a variable or constant with a descriptive name, or by writing a comment explaining what the number's purpose is. +- Packages should always have relative paths for their requires. Never require another module with an absolute path. Prefer string requires. +- Avoid forward declaration whenever possible. ## Documentation Style - Public single-line docs: `---` @@ -45,6 +43,8 @@ Use `npm run setup ` when deeper local package context is needed. - Private single-line comments: `--` - Private multi-line comments: `--[[]]` +Documentation is in moonwave format. + ## Constant Policy Use `const` whenever a variable binding is never reassigned. @@ -53,7 +53,7 @@ Important notes: - Base-scope constants should use SCREAMING_SNAKE_CASE unless they are mutable-content tables. - If a variable is declared and immediately assigned later, treat it as non-const for policy purposes. - Exceptions to the constant naming policy may be made when justified by readability, practicality, or consistency with existing code. Ex: Roblox services and required modules should be consts at the base scope, but they should be PascalCase even though they are never reassigned. -- local functions should almost always be const. +- Functions should almost always be const. `const function` is preferred over `local function` for functions that are never reassigned. This is because it makes it clear that the function is not intended to be reassigned, and it can help prevent accidental reassignment. Declaration matrix: @@ -70,11 +70,13 @@ Declaration matrix: | any | Inner | Assigned programmatically | local with camelCase | ## Testing Rules -- Use tiniest for tests. Analyze the examples under `/test/tiniest` for reference. -- TestEZ is deprecated in this codebase. NEVER use TestEZ or its syntax. They do not exist. +- `tiniest` is the testing framework we use for running tests. If needed, analyze the examples under `/test/tiniest` for reference. +- Utilize `tiniest`'s `describe`, `test`, `expect`, and `context` functions for structuring tests and assertions. Context is useful for debugging test cases by providing custom additional info. Attach context to failing tests to surface that info in the test output. - Create tests in `.spec.luau` files. -- For pure Luau packages, run tests through `tests.luau` in VS Code. +- Do not attempt to run tests through command line or anything else yourself unless you are explicitly instructed to. +- For pure Luau packages, you can run tests through `tests.luau` in VS Code. - For non-pure-Luau packages, rely on developer-run manual testing and provided output. +- TestEZ is deprecated in this codebase. NEVER use TestEZ or its syntax. They do not exist. ## Debugging Rules - Never assume root cause without validating with test output. @@ -83,5 +85,8 @@ Declaration matrix: - Prefer extra context over minimal logs so follow-up decisions can be made from one run. ## Planning -- While planning, you must should come up with a flowchart to explain the current way things work and a separate flowchart to explain the new proposal. If a plan includes multiple separate changes, you need multiple separate before and after flowcharts. -- You must always include a list of to-dos in the final plan and they should be broken into discrete tasks that an agent can be tasked with. \ No newline at end of file +- Plans should be broken up into phases that can be executed independently without breaking a system. Each phase should have a clear goal and a defined-ordered set of tasks. +- Phases should note touched files and the expected impact on those files. This helps with code review and ensures that changes are intentional and well-understood. + +## Reference Links +- If creating a type function, view the following: https://luau.org/types-library/ diff --git a/.gitignore b/.gitignore index a351bdf9..aee87359 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.rbxl *.rbxl.lock +*.pyc src/baseobject/* !src/baseobject/baseobject @@ -23,4 +24,5 @@ last_tested_package.txt !**/wally.toml !**/default.project.json +!**/README.md node_modules diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..eb5a2f7a --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +See .github/copilot-instructions.md for a guide on this repository. \ No newline at end of file diff --git a/default.project.json b/default.project.json index 0ab33f25..4ebd7e42 100644 --- a/default.project.json +++ b/default.project.json @@ -3,35 +3,6 @@ "emitLegacyScripts": false, "tree": { "$className": "DataModel", - - "Workspace": { - "$className": "Workspace", - "BasePlate":{ - "$className": "Part", - "$properties": { - "Size": [1024, 4, 1024], - "Position": [0, -2, 0], - "Color": { - "Color3uint8": [91, 91, 91] - }, - "Anchored": true, - "Locked": true, - "TopSurface": "Smooth" - }, - - "GridTexture": { - "$className": "Texture", - "$properties": { - "Texture": "rbxassetid://6372755229", - "StudsPerTileU": 8, - "StudsPerTileV": 8, - "Color3": [0, 0, 0], - "Transparency": 0.8, - "Face": "Top" - } - } - } - }, "ReplicatedStorage": { "$className": "ReplicatedStorage", @@ -43,60 +14,6 @@ } }, - "Lighting": { - "$className": "Lighting", - "$properties": { - "Ambient": { - "Color3uint8": [70, 70, 70] - }, - "OutdoorAmbient": { - "Color3uint8": [70, 70, 70] - }, - "GlobalShadows": true, - "Brightness": 3, - "ShadowSoftness": 0.2, - "EnvironmentSpecularScale": 1, - "EnvironmentDiffuseScale": 1, - "GeographicLatitude": 0, - "ClockTime": 14.5, - "Technology": "Future" - }, - - "Sky": { - "$className": "Sky", - "$properties": { - "MoonTextureId": "rbxassetid://6444320592", - "SkyboxBk": "rbxassetid://6444884337", - "SkyboxDn": "rbxassetid://6444884785", - "SkyboxFt": "rbxassetid://6444884337", - "SkyboxLf": "rbxassetid://6444884337", - "SkyboxRt": "rbxassetid://6444884337", - "SkyboxUp": "rbxassetid://6412503613", - "CelestialBodiesShown": false, - "StarCount": 3000, - "MoonAngularSize": 11, - "SunAngularSize": 11, - "SunTextureId": "rbxassetid://6196665106" - } - }, - - "Atmopshere": { - "$className": "Atmosphere", - "$properties": { - "Density": 0.3, - "Offset": 0.25, - "Color": { - "Color3uint8": [199, 199, 199] - }, - "Decay": { - "Color3uint8": [106, 112, 125] - }, - "Glare": 0, - "Haze": 0 - } - } - }, - "HttpService": { "$className": "HttpService", "$properties": { diff --git a/lib/tablemanager/src/TableState.luau b/lib/tablemanager/src/TableState.luau deleted file mode 100644 index f0346753..00000000 --- a/lib/tablemanager/src/TableState.luau +++ /dev/null @@ -1,172 +0,0 @@ --- Authors: Logan Hunt (Raildex) --- January 10, 2024 ---[=[ - @class TableState - - This class is used to more easily observe and modify values in a TableManager. - - :::caution - TableState is not feature complete and is subject to change. - ::: -]=] - ---// Imports //-- -local SuperClass = require(script.Parent.Parent.BaseObject) -type TableManager = any -type CanBeArray = T | {T} -type Path = any - --------------------------------------------------------------------------------- ---// CLASS //-- --------------------------------------------------------------------------------- - -local TableState = setmetatable({}, SuperClass) -TableState.ClassName = "TableState" -TableState.__index = TableState - ---[=[ - Creates a new TableState. This is used to observe and modify values in a TableManager easier. - Equivalent to `tblMngr:ToState(Path)` - ```lua - local tbl = { - Coins = 0; - Inventory = { - "Sword"; - "Shield"; - }; - } - - local tblMngr = TableManager.new(tbl) - - local coinsState = TableState.new(tblMngr, "Coins") - print( coinsState == tblMngr:ToTableState("Coins") ) -- true - - coinsState:Set(100) -- equivalent to `tblMngr:SetValue("Coins", 100)` - - local inventoryState = TableState.new(tblMngr, "Inventory") - inventoryState:Insert("Potion") -- equivalent to `tblMngr:ArrayInsert("Inventory", "Potion")` - ``` - :::warning States with array values - You should avoid setting states to be a particular index in array because if the array is shifted - then the state can potentially be pointing to the wrong value. - ::: -]=] -function TableState.new(manager: TableManager, Path: Path): TableState - return manager:ToTableState(Path) -- Registers the state to the manager and then calls ._new() -end - ---[=[ - @private -]=] -function TableState._new(manager: TableManager, Path: Path) - local self = setmetatable(SuperClass.new(), TableState) - - self._Manager = manager - self._ArrayPath = manager.PathToArray(Path) - - self._RawValue = self._Manager:Get(self._ArrayPath) - - self:RegisterSignal("Changed") - self:RegisterSignal("ArraySet") - self:RegisterSignal("ArrayInsert") - self:RegisterSignal("ArrayRemove") - - self:AddTask(self._Manager:ListenToValueChange(self._ArrayPath, function(new, old) - self._RawValue = new - self:FireSignal("Changed", new, old) - end)) - - self:AddTask(self._Manager:ListenToArraySet(self._ArrayPath, function(index, new, old) - self:FireSignal("ArraySet", index, new, old) - end)) - - self:AddTask(self._Manager:ListenToArrayInsert(self._ArrayPath, function(index, value) - self:FireSignal("ArrayInsert", index, value) - end)) - - self:AddTask(self._Manager:ListenToArrayRemove(self._ArrayPath, function(index, value) - self:FireSignal("ArrayRemove", index, value) - end)) - - - self:AddTask(self._Manager:GetDestroyedSignal():Once(function() - self:Destroy() - end)) - - return self -end - ---[=[ - Sets the value this state is associated with. - ```lua - :Set(999) -- Sets the value itself to 999 - :Set(1, 999) -- Sets the value at index 1 to 999 (State must be an array) - ``` -]=] -function TableState:Set(...: any) - self._Manager:Set(self._ArrayPath, ...) -end - - ---[=[ - Gets the value this state is associated with. - Takes an optional argument to specify the index of the array to get. - ```lua - :Get() -- Gets the value itself - :Get(1) -- Gets the value at index 1 of the state (State must be an array) (Equivalent to :Get()[1]) - ``` -]=] -function TableState:Get(index: number?): any - return if index then self._RawValue[index] else self._RawValue; -end - ---[=[ - Increments the value this state is associated with. - ```lua - :Increment(999) -- Increments the value itself by 999 - :Increment(1, 999) -- Increments the value at index 1 by 999 (State must be an array) - ``` -]=] -function TableState:Increment(...: any): any - return self._Manager:Increment(self._ArrayPath, ...) -end - ---[=[ - Inserts a value into the array this state is associated with. - ```lua - :Insert(999) -- Appends 999 onto the array - :Insert(5, 999) -- Inserts 999 at index 5 of the array - ``` -]=] -function TableState:Insert(...: any) - self._Manager:ArrayInsert(self._ArrayPath, ...) -end - ---[=[ - Removes the value at the given index from the array this state is associated with. - @return any -- The removed value. -]=] -function TableState:Remove(index: number): any - return self._Manager:ArrayRemove(self._ArrayPath, index) -end - ---[=[ - Removes the first value that matches the given value from the array this state is associated with. - @return number -- The index of the removed value. -]=] -function TableState:RemoveFirstValue(valueToFind: any): number? - return self._Manager:ArrayRemoveFirstValue(self._ArrayPath, valueToFind) -end - ---[=[ - Observes changes to the value this state is associated with. Also fires immediately. - See [TableManager:Observe](TableManager.md#observe) for more information. -]=] -function TableState:Observe(fn: (new: any) -> ()): (() -> ()) - return self:AddTask(self._Manager:Observe(self._ArrayPath, fn)) -end - - -export type TableState = typeof(TableState.new({}, {})) - -return TableState \ No newline at end of file diff --git a/lib/tablemanager/src/init.luau b/lib/tablemanager/src/init.luau deleted file mode 100644 index 0cdafc76..00000000 --- a/lib/tablemanager/src/init.luau +++ /dev/null @@ -1,1609 +0,0 @@ --- Authors: Logan Hunt (Raildex) --- January 04, 2024 ---[=[ - @class TableManager - - A class for managing a table such that you can listen to changes and modify values easily. - TableManager is designed to provide robust listener functionality at the cost of some performance. - - **EXAMPLE USAGE** - ```lua - local tbl = { - Coins = 0; - Inventory = { - "Sword"; - "Shield"; - }; - } - - local tblMngr = TableManager.new(tbl) - - tblMngr:SetValue("Coins", 100) - tblMngr:IncrementValue("Coins", 55) - print(tblMngr:Get("Coins")) -- 155 - - tblMngr:ArrayInsert("Inventory", "Potion") - tblMngr:ArrayInsert("Inventory", 2, "Bow") - print(tblMngr:Get("Inventory")) -- {"Sword", "Bow", "Shield", "Potion"} - ``` - - :::tip - The TableManager has some methods to combine functionality for both values and arrays. - It will redirect to the proper method depending on your given arguments. - ```lua - :Set() -- Redirects to :SetValue() or :ArraySet() - :Increment() -- Redirects to :IncrementValue() or :ArrayIncrement() - :Update() -- Redirects to :UpdateValue() or :UpdateArray() - ``` - ::: - :::info Signals - TableManager has Signals you can access if you want to utilize the raw events with libraries - that can take advantage of signals like Promises. - ```lua - :GetSignal("ValueChanged") - :GetSignal("ArraySet") - :GetSignal("ArrayInsert") - :GetSignal("ArrayRemove") - ``` - ::: -]=] - ---// Imports //-- -local Packages = script.Parent -local TableState = require(script.TableState) -local Signal = require(Packages.Signal) -local Fusion = require(Packages.Fusion) -local Promise = require(Packages.Promise) -local BaseObject = require(Packages.BaseObject) -local SuperClass = BaseObject - ---// Types //-- -type TableState = TableState.TableState -type FusionState = Fusion.StateObject -type Promise = typeof(Promise.new()) - -type Signal = Signal.ScriptSignal -type SignalInternal = Signal & {_head: any} -type Connection = Signal.ScriptConnection - -type Numeric = number | Vector2 | Vector3 | CFrame | table | any -type table = {[any]: any} - --- Internal type -type ListenerContainer = { - ChildListeners: {[any]: ListenerContainer?}; - ValueChanged: Signal?; - ArraySet: Signal?; - ArrayInsert: Signal?; - ArrayRemove: Signal?; - FusionState: FusionState?; -} - ---[=[ - @within TableManager - @type CanBeArray T | {T} - A type that could be an individual of the type or an array of the type. -]=] -type CanBeArray = T | {T} - ---[=[ - @within TableManager - @type Path string | {any} - A path to a value in a table. - Can be written as a string in dot format or an array of strings. - :::Note - The array format is faster to parse and should be used when possible. - ::: - ```lua - local tbl = { - MyPath = { - To = { - Value = 0; - }; - }; - } - - local path1: Path = "MyPath.To.Value" -- Style 1 - local path2: Path = {"MyPath", "To", "Value"} -- Style 2 - ``` -]=] -export type Path = CanBeArray -type PathArray = {any} - ---[=[ - @within TableManager - @type ValueListenerFn (newValue: any, oldValue: any?, changeMetadata: ChangeMetadata?) -> () -]=] -type ValueListenerFn = (newValue: any?, oldValue: any?, changeMetadata: ChangeMetadata?) -> () - ---// Constants //-- -local ROOT_TABLE_PATH = {} -local WEAK_MT = {__mode = "k"} - ---[=[ - @within TableManager - @type ListenerType "ValueChanged" | "ArraySet" | "ArrayInsert" | "ArrayRemove" - This information is mostly for internal use. -]=] -type ListenerType = string -local ChildListeners = "ChildListeners" -local ListenerTypeEnum = table.freeze { - ValueChanged = "ValueChanged"; - ArraySet = "ArraySet"; - ArrayInsert = "ArrayInsert"; - ArrayRemove = "ArrayRemove"; - FusionState = "FusionState"; -} - ---[=[ - @within TableManager - @type DataChangeSource "self" | "child" | "parent" - This information is mostly for internal use. -]=] -type DataChangeSource = "self" | "child" | "parent" -local DataChangeSourceEnum = table.freeze { - SelfChanged = "self"; - ChildChanged = "child"; - ParentChanged = "parent"; -} - ---[=[ - @within TableManager - @interface ChangeMetadata - .ListenerType ListenerType -- The listener type that was fired. - .SourceDirection DataChangeSource -- The source direction of the change. - .SourcePath {string} -- The origin path of the change. - .NewValue any? -- [Only for value changes] The new value. - .OldValue any? -- [Only for value changes] The old value. - - Metadata about the change that fired a listener. Used to provide more context to listeners. - Allows you to figure out where the change came from, if it wasnt a direct change. -]=] -type ChangeMetadata = { - ListenerType: ListenerType; - SourceDirection: DataChangeSource; - SourcePath: {string}; - NewValue: any?; - OldValue: any?; -} - ---// Volatiles //-- --- Cache of string paths to arrays. Helps make PathToArray faster when reusing string paths. -local stringToArrayCache: {[string]: {string}} = {} - --- Tables being managed by TableManagers. -local managedTables: {[table]: TableManager} = setmetatable({}, {__mode = "kv"}) :: any - - --------------------------------------------------------------------------------- - --// Utility Fns //-- --------------------------------------------------------------------------------- - -local function AssertPathIsValid(path: Path) -- This method was deprecated with the introduction of non string paths - --assert((typeof(path) == "string" or typeof(path) == "table"), "Path is required!") -end - -local function AssertFnExists(fn: (...any) -> ()) - assert(type(fn) == "function", "Function is required!") -end - --- a recursive function to check deep equality of a table. Stops if it detects a cyclic reference. -local function deepEqual(tbl1, tbl2) - if type(tbl1) ~= "table" or type(tbl2) ~= "table" then - return tbl1 == tbl2 - end - - local visited = {} - - local function deepEqualRecursive(tbl1, tbl2) - if visited[tbl1] or visited[tbl2] then - return true - end - - visited[tbl1] = true - visited[tbl2] = true - - for key, value in pairs(tbl1) do - if tbl2[key] ~= value then - return false - end - end - - for key, value in pairs(tbl2) do - if tbl1[key] ~= value then - return false - end - end - - for key, value in pairs(tbl1) do - if type(value) == "table" and not deepEqualRecursive(value, tbl2[key]) then - return false - end - end - - return true - end - - return deepEqualRecursive(tbl1, tbl2) -end - --- a function that makes a deep copy of a table and warns if it detects a cyclic reference. -local function DeepCopy(tbl) - local visited = {} - local function DeepCopyRecursive(tbl) - if visited[tbl] then - warn("Cyclic reference detected in DeepCopy!") - return nil - end - - visited[tbl] = true - - local newTbl = {} - for key, value in pairs(tbl) do - if type(value) == "table" then - newTbl[key] = DeepCopyRecursive(value) - else - newTbl[key] = value - end - end - - return newTbl - end - - return DeepCopyRecursive(tbl) -end - ---[[ - Ensures the given path is a parsable array. -]] -local function PathToArray(indexPath: Path): {string} - local pathType = typeof(indexPath) - if pathType == "table" then - return indexPath - elseif pathType == "nil" then - return ROOT_TABLE_PATH - elseif pathType == "string" then - if not stringToArrayCache[indexPath :: string] then - assert(type(indexPath) == "string", "Invalid indexPath type!") - local pathArray = indexPath:split(".") - if pathArray[1] == "" then - pathArray = {} - end - stringToArrayCache[indexPath] = pathArray - end - return stringToArrayCache[indexPath :: string] - end - return {indexPath :: string} -end - ---[[ - Ensures the given path is in the string path format. -]] -local function PathToString(indexPath: Path): string - if type(indexPath) == "string" then - return indexPath - end - - if typeof(indexPath) == "table" then - local success, result = pcall(function() - return table.concat(indexPath, ".") - end) - if success then - return result - end - - local stringPath = "" - local pathSize = #indexPath - for i = 1, pathSize do - stringPath = stringPath..(indexPath :: {any})[i] - if i < pathSize then - stringPath = tostring(stringPath).."." - end - end - end - - warn("Invalid path format!", indexPath) - return "" -end - ---[[ - Gets the table containing the value at the given path. - Effectively gets the second to last value in the path. - If the path is empty, it will return nothing. -]] -local function GetContainerTable(tbl: table, path: {string}): table? - local size = #path - if size > 0 then - for i = 1, size - 1 do - tbl = tbl[path[i]] - end - return tbl - end - return nil -end - --- iterates through a table using a given path and optional lastKey -local function FetchValueInTableFromPath(tbl: table, path: {string | number}, lastKey: string | number): any? - local currentValue = tbl - - if lastKey then - table.insert(path, lastKey) - end - - for i = 1, #path do - if typeof(currentValue) ~= "table" then - warn("Unable to reach end of path!") - return nil - end - currentValue = currentValue[path[i]] - end - - return currentValue -end - --------------------------------------------------------------------------------- - --// Class //-- --------------------------------------------------------------------------------- - -local TableManager = setmetatable({}, SuperClass) -TableManager.ClassName = "TableManager" -TableManager.__index = TableManager -TableManager.__call = function(t, ...: any): TableManager - return t.new(...) -end - --- FLAGS -TableManager.DEBUG = false -TableManager.FIRE_CHILD_LISTENERS = true -TableManager.FIRE_PARENT_LISTENERS = true - --- Exposed Utility Functions -TableManager.PathToString = PathToString -TableManager.PathToArray = PathToArray -TableManager.FetchValueInTableFromPath = FetchValueInTableFromPath - --- Exposed Enums ---[=[ - @within TableManager - @prop Enums {ListenerType: ListenerTypeEnum, DataChangeSource: DataChangeSourceEnum} - A collection of enums used by the TableManager. -]=] -TableManager.Enums = table.freeze { - ListenerType = ListenerTypeEnum; - DataChangeSource = DataChangeSourceEnum; -} - ---[=[ - @tag Constructor - Creates a new TableManager. Takes a table to manage, if one is not given then it will construct an empty table. - - :::warning Modifying the given table - Once you give a table to a `TableManager`, you should never modify it directly. - Doing so can result in the `TableManager` being unable to properly track changes - and potentially cause data desyncs. - ::: - - :::caution Key/Value Rules - The given table's keys should follow these rules: - - No Mixed Tables (Tables containing keys of different datatypes) - - Avoid using tables as keys. - - Keys *must* not contain periods. - - Keys *must* not be empty strings. - - Tables/Arrays should be assigned to only one key. (No shared references as this can cause desyncs) - - Nested tables/arrays should not be given to other `TableManager` instances. (Can cause desyncs) - ::: - - :::info - Only one `TableManager` should be created for a given table. Attempting to create a `TableManager` for a table - that is already being managed will return the existing `TableManager`. - ::: - - :::tip Call metamethod - You can call the `TableManager` class to create a new instance of it. - `TableManager()` is equivalent to `TableManager.new()`. - ```lua - local manager = TableManager { - Coins = 0; - Title = "Knight"; - } - ``` - ::: -]=] -function TableManager.new(data: table?): TableManager - data = data or {} - assert(type(data) == "table", "Data must be a table!") - - if managedTables[data] then - warn("TableManager already exists for this table!", data) - return managedTables[data] - end - - local self = setmetatable(SuperClass.new(), TableManager) - - self.Data = data - self._Data = data - self._TableStateStorage = {} :: {[string]: any} - - --// Private Signals //-- - self:RegisterSignal("_ValueBulkChange") - self:RegisterSignal("_ValueChange") - - --// Public Signals //-- - self:RegisterSignal("ValueChanged") - --self:RegisterSignal("ValueAdded") - - self:RegisterSignal("ArraySet") - self:RegisterSignal("ArrayInsert") - self:RegisterSignal("ArrayRemove") - - self._Listeners = {[ChildListeners] = {}} :: ListenerContainer - - -- Store ref to table so we dont accidentally duplicate it. - managedTables[self._Data] = self - - return self -end - - ---[=[ - Disconnects any listeners and removes the table from the managed tables. -]=] -function TableManager:Destroy() - managedTables[self._Data] = nil - getmetatable(TableManager).Destroy(self) -end - --------------------------------------------------------------------------------- - --// Combo Methods //-- --------------------------------------------------------------------------------- - ---[=[ - Fetches the value at the given path. - Accepts a string path or an array path. - Accepts an optional secondary argument to fetch a value at an index in an array. - Aliases: `GetValue` - - ```lua - local manager = TableManager.new({ - Currency = { - Coins = 100; - Gems = 10; - }; - }) - - -- The following are all equivalent acceptable methods of fetching the value. - print(manager:Get("Currency.Coins")) -- 100 - print(manager:Get({"Currency", "Coins"})) -- 100 - print(manager:Get().Currency.Coins) -- 100 - ``` - :::note Getting the Root Table - Calling `:Get()` with no arguments, an empty string, - or an empty table will return the root table. - ::: -]=] -function TableManager:Get(path: Path, idx: (number | string)?): any - --debug.profilebegin("TM:GetValue") - path = PathToArray(path or {}) - - local tblData = self._Data - for i = 1, #path do - local idx = (path :: {})[i] - if typeof(tblData) ~= "table" then - warn("Unable to reach end of path!", path, idx) - return nil - end - tblData = tblData[idx] - end - - if idx then - if typeof(tblData) ~= "table" then - warn("Unable to reach end of path!", path, idx) - return nil - end - tblData = tblData[idx] - end - - --debug.profileend() - return tblData -end - --------------------------------------------------------------------------------- - --// Setters //-- --------------------------------------------------------------------------------- - ---[=[ - Sets the value at the given path to the given value. - :Set acts as a combined function for :SetValue and :ArraySet. - ```lua - :Set(myPathToValue, newValue) - :Set(myPathToArray, index, newValue) - ``` - - :::caution Overwriting the root table - Overwriting the root table is not recommended, but is technically possible by giving - an empty table or string as a `Path`. Doing so has not been tested in depth and may - result in unintended behavior. - ::: - - :::caution Setting array values - You cannot set values to nil in an array with this method due to the way it parses args. - Use `ArraySet` instead if you need to set values to nil. - ::: -]=] -function TableManager:Set(path: Path, ...: any) - if select('#', ...) == 2 then - local index, value = ... - self:ArraySet(path, index, value) - else - local value = ... - self:SetValue(path, value) - end -end - ---[=[ - Increments the value at the given path by the given amount. - If the value is not a number, it will throw an error. - :Increment acts as a combined function for :IncrementValue and :ArrayIncrement. - ```lua - :Increment(myPathToValue, amountToIncrementBy) - :Increment(myPathToArray, index, amountToIncrementBy) - ``` -]=] -function TableManager:Increment(path: Path, ...: any): number? - if select('#', ...) == 2 then - local index, amount = ... - return self:ArrayIncrement(path, index, amount) - else - local amount = ... - return self:IncrementValue(path, amount) - end -end - ---[=[ - Mutates the value at the given path by calling the given function with the current value. - ```lua - :Update(myPathToValue, function(currentValue) - return currentValue + 1 - end) - ``` - :::info Aliases - `:Mutate` is an alias for `:Update`. This alias is consistent with all other 'Update' methods. - ::: -]=] -function TableManager:Update(path: Path, ...: any): any? - if select('#', ...) == 2 then - local index, fn = ... - return self:ArrayUpdate(path, index, fn) - else - local fn = ... - return self:UpdateValue(path, fn) - end -end -TableManager.Mutate = TableManager.Update - ---[=[ - Sets the value at the given path to the given value. - This will fire the ValueChanged signal if the value is different. - Returns a boolean indicating whether or not the value was changed. - ```lua - local didChange = manager:SetValue("MyPath.To.Value", 100) - ``` -]=] -function TableManager:SetValue(path: Path, value: any): boolean - debug.profilebegin("TM:SetValue") - local success = self:_SetValue(path, value) - if success then - self:FireSignal("_ValueChange", path, value) - end - debug.profileend() - return success -end - ---[=[ - Increments the value at the given path by the given amount. - If the value at the path or the given amount is not a number, - it will throw an error. Returns the newly incremeneted value. - ```lua - local newValue = manager:IncrementValue("MyPath.To.Value", 100) - ``` -]=] -function TableManager:IncrementValue(path: Path, amount: Numeric): number - local currentValue = self:GetValue(path) - --assert(type(amount) == "number", "Increment amount must be a number!") - --assert(type(currentValue) == "number", "Cannot increment a non-number value!") - local newValue = currentValue + amount - self:SetValue(path, newValue) - return newValue -end - ---[=[ - Mutates the value at the given path by calling the given function with the current value. - The function should return the new value. - ```lua - manager:SetValue("MyPath.To.Value", "Hello World") - - local newValue = manager:UpdateValue("MyPath.To.Value", function(currentValue) - return string.upper(currentValue) .. "!" - end) - - print(newValue) -- HELLO WORLD! - print(manager:GetValue("MyPath.To.Value")) -- HELLO WORLD! - ``` -]=] -function TableManager:UpdateValue(path: Path, fn: (currentValue: any) -> (any)): any - local currentValue = self:GetValue(path) - local newValue = fn(currentValue) - self:SetValue(path, newValue) - return newValue -end -TableManager.MutateValue = TableManager.UpdateValue - ---[=[ - Sets the values at the given path to the given values. - This will fire the ValueChanged listener for each value that is different. - :::caution - Uses pairs to check through the given table and thus *Does not support setting values to nil*. - ::: - ```lua - local manager = TableManager.new({ - Foo = { - Bar = { - Value1 = 0; - Value2 = 0; - Value3 = 0; - }; - }; - }) - - manager:SetManyValues("Foo.Bar", { - Value1 = 100; - Value3 = 300; - }) - ``` -]=] -function TableManager:SetManyValues(path: Path, valueDict: {[any]: any}) - debug.profilebegin("TM:SetValues") - path = PathToArray(path) - for key, value in pairs(valueDict) do - self:_SetValue(path, value, key) - end - self:FireSignal("_ValueBulkChange", path, valueDict) - debug.profileend() -end - ---[=[ - Mutates an index or indices in the array at the given path by calling the given function with the current value. - @param path -- The path to the array to mutate. - @param index number | {number} | "#" -- The index or indices to mutate. If "#" is given, it will mutate all indices. - @param fn -- The function to call with the current value. Should return the new value. - - ```lua - manager:SetValue("MyArray", {1, 2, 3, 4, 5}) - - manager:ArrayUpdate("MyArray", 3, function(currentValue) - return currentValue * 2 - }) - - print(manager:GetValue("MyArray")) -- {1, 2, 6, 4, 5} - ``` -]=] -function TableManager:ArrayUpdate(path: Path, index: CanBeArray | "#", fn: (currentValue: any) -> (any)) - local array = self:GetValue(path) - - if index == "#" then - for i = 1, #array do - local currentValue = array[i] - local newValue = fn(currentValue) - self:ArraySet(path, i, newValue) - end - else - if typeof(index) ~= "table" then - index = {index} - end - - assert(typeof(index) == "table", "Index must be able to resolve to a table of numbers!") - for i = 1, #index do - local idx = index[i] - local currentValue = array[idx] - local newValue = fn(currentValue) - self:ArraySet(path, idx, newValue) - end - end -end -TableManager.MutateArray = TableManager.ArrayUpdate -TableManager.UpdateArray = TableManager.ArrayUpdate -TableManager.ArrayMutate = TableManager.ArrayUpdate - ---[=[ - Increments the indices at the given path by the given amount. - @param path -- The path to the array to increment. - @param index number | {number} -- The index or indices to increment. - @param amount number? -- The amount to increment by. If not given, it will increment by 1. - - ```lua - manager:SetValue("MyArray", {1, 2, 3, 4, 5}) - - manager:ArrayIncrement("MyArray", 3, 10) - - print(manager:GetValue("MyArray")) -- {1, 2, 13, 4, 5} - ``` -]=] -function TableManager:ArrayIncrement(path: Path, index: CanBeArray | '#', amount: Numeric?) - debug.profilebegin("TM:ArrayIncrement") - - local arrayPath = PathToArray(path) - local containerArray = self:GetValue(arrayPath) - if typeof(containerArray) ~= "table" then - warn("RawData:", self._Data) - error(`Cannot ArrayIncrement a non-array! Value at Path: {containerArray}, Path: {PathToString(path)}`) - end - local containerArraySize = #containerArray - - if type(index) ~= "table" then - if not index or index == '#' then - index = {} - for i = 1, containerArraySize do - table.insert(index :: any, i) - end - else - index = {index} - end - end - - amount = amount or 1 - assert(typeof(index) == "table", "Index must be able to resolve to a table of numbers!") - for i = 1, #index do - local idx = index[i] - assert(idx <= containerArraySize, "Index out of bounds!") - - local fullPath = table.clone(arrayPath) - table.insert(fullPath, idx) - - local prevValue = containerArray[idx] - local newValue = prevValue + amount - containerArray[idx] = newValue - - self:FireSignal(ListenerTypeEnum.ArraySet, arrayPath, idx, newValue, prevValue) - self:_FireListeners({ - Path = fullPath; - ArrayPath = arrayPath; - ArrayIndex = idx; - ListenerType = ListenerTypeEnum.ArraySet; - NewValue = newValue; - OldValue = prevValue; - }) - end - debug.profileend() -end - ---[=[ - Sets the value at the given index in the array at the given path. - The index can be a number or an array of numbers. If an array is given then - the value will be set at each of those indices in the array. -]=] -function TableManager:ArraySet(path: Path, index: (CanBeArray | '#')?, value: any) - debug.profilebegin("TM:ArraySet") - - local arrayPath = PathToArray(path) - local containerArray = self:GetValue(arrayPath) - if typeof(containerArray) ~= "table" then - warn("RawData:", self._Data) - error(`Cannot ArraySet a non-array value! Value at Path: {containerArray}, Path: {PathToString(path)}`) - end - local containerArraySize = #containerArray - - if type(index) ~= "table" then - if not index or index == '#' then - index = {} - for i = 1, containerArraySize do - table.insert(index :: any, i) - end - else - index = {index} - end - end - - assert(typeof(index) == "table", "Index must be able to resolve to a table of numbers!") - for i = 1, #index do - local idx = index[i] - if type(idx) ~= "number" then - error(`{tostring(idx)} is not a valid index! (Expected: 'number', Got: {typeof(idx)}`) - elseif idx > containerArraySize then - warn(("Index[%d] out of bounds[%d]! Consider using ArrayInsert instead."):format(idx, containerArraySize)) - end - - local prevValue = containerArray[idx] - if prevValue == value then - continue - end - containerArray[idx] = value - - local fullPath = table.clone(arrayPath) - table.insert(fullPath, idx) - - self:FireSignal(ListenerTypeEnum.ArraySet, arrayPath, idx, value, prevValue) - self:_FireListeners({ - Path = fullPath; - ArrayPath = arrayPath; - ArrayIndex = idx; - ListenerType = ListenerTypeEnum.ArraySet; - NewValue = value; - OldValue = prevValue; - }) - end - debug.profileend() -end - ---[=[ - Inserts the given value into the array at the given path at the given index. - If no index is given, it will insert at the end of the array. - This follows the convention of `table.insert` where the index is given in the middle - only if there are 3 args. - ```lua - x:ArrayInsert("MyArray", "Hello") -- Inserts "Hello" at the end of the array - x:ArrayInsert("MyArray", 1, "Hello") -- Inserts "Hello" at index 1 - x:ArrayInsert("MyArray", 1) -- appends 1 to the end of the array - x:ArrayInsert("MyArray", 1, 2) -- Inserts 2 at index 1 - ``` -]=] -function TableManager:ArrayInsert(path: Path, ...: any) - debug.profilebegin("TM:ArrayInsert") - local containerArray = self:GetValue(path) - - local argCount = select('#', ...) - local index, value - if argCount == 1 then - value = ... - index = #containerArray + 1 - elseif argCount == 2 then - index, value = ... - else - error("Invalid number of arguments!") - end - - local currentValue = containerArray[index] - table.insert(containerArray, index, value) - - - local arrayPath = PathToArray(path) - local fullPath = table.clone(arrayPath) - table.insert(fullPath, index) - - self:FireSignal(ListenerTypeEnum.ArrayInsert, arrayPath, index, value) - self:_FireListeners({ - Path = fullPath; - ArrayPath = arrayPath; - ArrayIndex = index; - ListenerType = ListenerTypeEnum.ArrayInsert; - NewValue = value; - OldValue = currentValue; - }) - debug.profileend() -end - ---[=[ - Removes the value at the given index from the array at the given path. - If no index is given, it will remove the last value in the array. - Returns the value that was removed if one was. -]=] -function TableManager:ArrayRemove(path: Path, index: number?): any - debug.profilebegin("TM:ArrayRemove") - - local containerArray = self:GetValue(path) - assert(index == nil or typeof(index) == "number", "Index must be a number or nil!") - assert(typeof(containerArray) == "table", "Cannot remove from a non-array!") - - index = index or #containerArray - local previousValue = table.remove(containerArray, index) - local newValue = containerArray[index] - - local arrayPath = PathToArray(path) - local fullPath = table.clone(arrayPath) - table.insert(fullPath, index) - - self:FireSignal(ListenerTypeEnum.ArrayRemove, arrayPath, index, previousValue) - self:_FireListeners({ - Path = fullPath; - ArrayPath = arrayPath; - ArrayIndex = index; - ListenerType = ListenerTypeEnum.ArrayRemove; - NewValue = newValue; - OldValue = previousValue; - }) - debug.profileend() - return previousValue -end - ---[=[ - Removes the first instance of the given value from the array at the given path. - Returns a number indicating the index that it was was removed from if one was. -]=] -function TableManager:ArrayRemoveFirstValue(path: Path, value: any): number? - debug.profilebegin("TM:ArrayRemoveFirstValue") - local containerArray = self:GetValue(path) - - local index = table.find(containerArray, value) - if index then - local previousValue = table.remove(containerArray, index) - local newValue = containerArray[index] - - local arrayPath = PathToArray(path) - local fullPath = table.clone(arrayPath) - table.insert(fullPath, index) - - self:FireSignal(ListenerTypeEnum.ArrayRemove, arrayPath, index, previousValue) - self:_FireListeners({ - Path = fullPath; - ArrayPath = arrayPath; - ArrayIndex = index; - ListenerType = ListenerTypeEnum.ArrayRemove; - NewValue = newValue; - OldValue = previousValue; - }) - end - debug.profileend() - return index -end - - --- function TableManager:ArraySwapRemove(path: Path, index: number) --- -- TODO: Implement --- end - - --- function TableManager:ArraySwapRemoveFirstValue(path: Path, value: any) --- -- TODO: Implement --- end - - --------------------------------------------------------------------------------- - --// Getters //-- --------------------------------------------------------------------------------- - -TableManager.GetValue = TableManager.Get - ---[=[ - @unreleased - @private - Returns a TableState Object for the given path. - :::warning - This method is not feature complete and does not work for all edge cases and should be used with caution. - ::: - ```lua - local path = "MyPath.To.Value" - local state = manager:ToTableState(path) - - state:Set(100) - manager:Increment(path, 50) - state:Increment(25) - - print(state:Get()) -- 175 - ``` -]=] -function TableManager:ToTableState(path: Path): TableState - local stringPath = PathToString(path) - local state = self._TableStateStorage[stringPath] - if not state then - state = TableState._new(self, path) - self._TableStateStorage[stringPath] = state - state:AddTask(function() - self._TableStateStorage[stringPath] = nil - end) - end - return state -end - ---[=[ - Returns a Fusion State object that is bound to the value at the given path. - This method is memoized so calling it repeatedly with the same path will - return the same State object and quickly. - ```lua - local path = "MyPath.To.Value" - - manager:SetValue(path, 100) - local state = manager:ToFusionState(path) - print(peek(state)) -- 100 - - manager:SetValue(path, 200) - task.wait() -- If your signals are deffered then the state will update on the next frame - print(peek(state)) -- 200 - ``` - - :::caution Deffered Signals - The value of the Fusion State object is updated via the ValueChanged listener - and thus may be deffered if your signals are deffered. - ::: - :::caution Setting - Although this currently returns a Fusion Value object, it is not recommended to set the value - as this may be a Computed in the future. Setting the state will not actually change the value - in the TableManager. - ::: - :::info Version - This method uses Fusion 0.3.0 internally so it may not work with older versions. - ::: -]=] -function TableManager:ToFusionState(path: Path): FusionState - local pathArray = PathToArray(path) - return self:_UpsertListenerTableForPath(ListenerTypeEnum.FusionState, pathArray) :: FusionState -end - --------------------------------------------------------------------------------- - --// Listeners //-- --------------------------------------------------------------------------------- - ---[=[ - Creates a promise that resolves when the given condition is met. The condition is immediately and - every time the value changes. If no condition is given then it will resolve with the current value - unless it is nil, in which case it will resolve on the first change. -]=] -function TableManager:PromiseValue(path: Path, condition: (value: any?) -> (boolean)): Promise - local currentValue = self:GetValue(path) - if (not condition and currentValue ~= nil) or (condition and condition(currentValue)) then - return Promise.resolve(currentValue) - end - - local connection: any - local prom = self:AddPromise(Promise.new(function(resolve, _, onCancel) - connection = self:ListenToValueChange(path, function(...) - if not condition or condition(...) then - resolve(...) - end - end) - - onCancel(function() - connection:Disconnect() - end) - end)) - - prom:finally(function() - connection:Disconnect() - end) - return prom -end - ---[=[ - Observes a value at a path and calls the function immediately with the current value, as well as when it changes. - :::caution Listening to nil values - It will *NOT* fire if the new/starting value is nil, unless runOnNil is true. When it changes from nil, the oldValue will - be the last known non nil value. The binding call of the function is an exception and will give nil as the oldValue. - This is done so that Observe can be used to execute instructions when a value is percieved as 'ready'. - ::: - - @param path -- The path to the value to observe. - @param fn -- The function to call when the value changes. - @param runOnNil -- Whether or not to fire the function when the value is nil. - - @return Connection -- A connection used to disconnect the listener. - - ```lua - local path = "MyPath.To.Value" - local connection = manager:Observe(path, function(newValue) - print("Value at", path, "is", newValue) - end) - - connection() -- Disconnects the listener - ``` -]=] -function TableManager:Observe(path: Path, fn: ValueListenerFn, runOnNil: boolean?): Connection - AssertPathIsValid(path) - AssertFnExists(fn) - - local fakeOldValue = self:GetValue(path) - local connection = self:ListenToValueChange(path, function(newValue, _, metadata) - if newValue ~= fakeOldValue or typeof(newValue) == "table" then - if newValue ~= nil or runOnNil == true then - fn(newValue, fakeOldValue, metadata) - fakeOldValue = newValue - end - end - end) - - if fakeOldValue ~= nil or runOnNil == true then - fn(fakeOldValue, nil, nil) - end - - return connection -end - - ---[=[ - @unreleased - Listens to a change at a specified path and calls the function when the value changes. - - :::warning keys with table values - This method is not yet fully implemented and may not work as expected when listening to keys with table values or changing - data from parent table. - ::: - - ```lua - manager:Set("Stats", { - Health = 100; - Mana = 50; - }) - - local connection = manager:OnKeyChange("Stats", function(key, newValue) - print(`{key} changed to {newValue}`) - end) - - manager:SetValue("Stats.Health", 200) -- Health changed to 200 - manager:SetValue("Stats.Mana", 100) -- Mana changed to 100 - ``` -]=] -function TableManager:OnKeyChange(parentPath: Path?, fn: (keyChanged: any, newValue: any, oldValue: any, changeMetadata: ChangeMetadata?) -> ()) - AssertPathIsValid(parentPath) - AssertFnExists(fn) - - local pathArray = PathToArray(parentPath) - - local lastRecorded = self:Get(parentPath) - if type(lastRecorded) ~= "table" then - warn("Attempting to listen to key changes on a non-table value! This may result in incorrect or unexpected behavior.\n" - .."Key Path:", PathToString(parentPath), "| Value:", lastRecorded, "\n", debug.traceback()) - lastRecorded = {} - else - lastRecorded = table.clone(lastRecorded) - end - - return self:_AddToListeners(ListenerTypeEnum.ValueChanged, parentPath, function(_, _, metadata) - - -- make a copy for comparison - local newTbl = self:Get(parentPath) - if type(newTbl) ~= "table" then - newTbl = {} - end - - local isDescendantKey = (metadata.SourceDirection == DataChangeSourceEnum.ChildChanged) and (pathArray[#pathArray] == metadata.SourcePath[#pathArray]) - local isChildKey = isDescendantKey and #metadata.SourcePath == #pathArray - - if isChildKey then - local keyThatChanged = metadata.SourcePath[#pathArray + 1] - fn(keyThatChanged, newTbl[keyThatChanged], lastRecorded[keyThatChanged], metadata) - else - -- TODO: This should be optimized to only check the keys that may have changed. - -- TODO: This method is unfinished and may not work as expected. I still need to make it properly detect changes from parent tables - - warn("TableManager:OnKeyChange is not fully implemented and may not work as expected.") - - -- print("Checking for diffs", newTbl, metadata, lastRecorded) - -- print("Key that was changed", metadata.SourcePath[#metadata.SourcePath]) - - local diffs = {} - for key, newValue in pairs(newTbl) do - local oldValue = lastRecorded[key] - if oldValue ~= newValue or (type(newValue) == "table" and not deepEqual(oldValue, newValue)) then - diffs[key] = {newValue, oldValue} - end - end - - for key, oldValue in pairs(lastRecorded) do - local newValue = newTbl[key] - if oldValue ~= newValue or (type(newValue) == "table" and not deepEqual(oldValue, newValue)) then - diffs[key] = {newValue, oldValue} - end - end - - for key, diff in pairs(diffs) do - fn(key, diff[1], diff[2]) - end - end - - lastRecorded = newTbl - end) -end -TableManager.ListenToKeyChange = TableManager.OnKeyChange - ---[=[ - @unreleased - Listens to when a new key is added (Changed from nil) to a table at a specified path and calls the function. -]=] -function TableManager:OnKeyAdd(parentPath: Path?, fn: (newKey: any, newValue: any) -> ()): Connection - return self:ListenToKeyChange(parentPath, function(key, newValue, oldValue) - if oldValue == nil then - fn(key, newValue) - end - end) -end -TableManager.ListenToKeyAdd = TableManager.OnKeyAdd - ---[=[ - @unreleased - Listens to when a key is removed (Set to nil) from a table at a specified path and calls the function. -]=] -function TableManager:OnKeyRemove(parentPath: Path?, fn: (removedKey: any, lastValue: any) -> ()): Connection - return self:ListenToKeyChange(parentPath, function(key, newValue, _) - if newValue == nil then - fn(key, newValue) - end - end) -end -TableManager.ListenToKeyRemove = TableManager.OnKeyRemove - ---[=[ - Listens to a change at a specified path and calls the function when the value changes. - This does NOT fire when the value is an array/dictionary and one of its children changes. - ```lua - local connection = manager:OnValueChange("MyPath.To.Value", function(newValue, oldValue) - print("Value changed from", oldValue, "to", newValue) - end) - - connection() -- Disconnects the listener - ``` -]=] -function TableManager:OnValueChange(path: Path, fn: ValueListenerFn): Connection - AssertPathIsValid(path) - AssertFnExists(fn) - return self:_AddToListeners(ListenerTypeEnum.ValueChanged, path, fn) -end -TableManager.ListenToValueChange = TableManager.OnValueChange - ---[=[ - Listens to when an index is set in an array at a specified path and calls the function. - The function receives the index and the new value. - :::caution - The array listeners do not fire from changes to parent or child values. - ::: -]=] -function TableManager:OnArraySet(path: Path, fn: (changedIndex: number, newValue: any) -> ()): Connection - AssertPathIsValid(path) - AssertFnExists(fn) - return self:_AddToListeners(ListenerTypeEnum.ArraySet, path, fn) -end -TableManager.ListenToArraySet = TableManager.OnArraySet - ---[=[ - Listens to when a value is inserted into an array at a specified path and calls the function when the value changes. -]=] -function TableManager:OnArrayInsert(path: Path, fn: (changedIndex: number, newValue: any) -> ()): Connection - AssertPathIsValid(path) - AssertFnExists(fn) - return self:_AddToListeners(ListenerTypeEnum.ArrayInsert, path, fn) -end -TableManager.ListenToArrayInsert = TableManager.OnArrayInsert - ---[=[ - Listens to when a value is removed from an array at a specified path and calls the function. -]=] -function TableManager:OnArrayRemove(path: Path, fn: (oldIndex: number, oldValue: any) -> ()): Connection - AssertPathIsValid(path) - AssertFnExists(fn) - return self:_AddToListeners(ListenerTypeEnum.ArrayRemove, path, fn) -end -TableManager.ListenToArrayRemove = TableManager.ListenToArrayRemove - - --- function TableManager:GetValueChangedSignal(path: Path): Signal --- local listeners = self:_UpsertListenerTableForPath(ListenerTypeEnum.ValueChanged, PathToArray(path)) --- return listeners[ListenerTypeEnum.ValueChanged] --- end - - --------------------------------------------------------------------------------- - --// Private //-- --------------------------------------------------------------------------------- - ---[=[ - @private - Gets the top level table being managed by this TableManager. -]=] -function TableManager:_GetRawData() - return self._Data -end - ---[=[ - @private -]=] -function TableManager:_AddToListeners(listenerType: ListenerType, path: Path, listenerFn: (...any) -> ()): Connection - AssertFnExists(listenerFn) - - path = path or {} - local pathArray = PathToArray(path) - - local listenerSignal = self:_UpsertListenerTableForPath(listenerType, pathArray) - - if listenerType == ListenerTypeEnum.ValueChanged then - local lastValue = self:Get(path) - return listenerSignal:Connect(function(newValue, metadata) - if newValue ~= lastValue or typeof(newValue) == "table" then - listenerFn(newValue, lastValue, metadata) - lastValue = newValue - end - end) - - else - return listenerSignal:Connect(listenerFn) - -- if listenerType == ListenerTypeEnum.ArraySet then - -- return listenerSignal:Connect(listenerFn) - -- elseif listenerType == ListenerTypeEnum.ArrayInsert then - -- return listenerSignal:Connect(listenerFn) - -- elseif listenerType == ListenerTypeEnum.ArrayRemove then - -- return listenerSignal:Connect(listenerFn) - -- end - end -end - ---[=[ - @private - Creates a listener table for the given path if it doesn't exist. - Returns the listener table. -]=] -function TableManager:_UpsertListenerTableForPath(listenerType: ListenerType, pathArray: PathArray): {[any]: any} - local listeners = self._Listeners :: ListenerContainer - - for i = 1, #pathArray do - local currentPathKey = pathArray[i] - local listenersForKey = listeners[ChildListeners][currentPathKey] - if listenersForKey == nil then - listenersForKey = {[ChildListeners] = (setmetatable({}, WEAK_MT) :: any) :: ListenerContainer} - listeners[ChildListeners][currentPathKey] = listenersForKey - end - listeners = listenersForKey - end - - local listenerTypeTable = listeners[listenerType] - if listenerTypeTable == nil then - if listenerType == ListenerTypeEnum.FusionState then - if not self._FScope then - self._FScope = Fusion.scoped() - self:AddTask(function() - Fusion.doCleanup(self._FScope) - end) - end - listenerTypeTable = Fusion.Value(self._FScope, self:Get(pathArray)) - self:ListenToValueChange(pathArray, function(newValue) - listenerTypeTable:set(newValue) - end) - else - listenerTypeTable = self:AddTask(Signal.new()) - end - listeners[listenerType] = listenerTypeTable - end - - return listenerTypeTable -end - ---[=[ - @private - Gets the listener signal for the given path if it exists. -]=] -function TableManager:_GetListenerSignalForPath(listenerType: ListenerType, pathArray: PathArray): SignalInternal? - local listeners = self._Listeners :: ListenerContainer - - for i = 1, #pathArray do - local currentPathKey = pathArray[i] - local listenersForKey = listeners[ChildListeners][currentPathKey] - if listenersForKey == nil then - return nil - end - listeners = listenersForKey - end - - return listeners[listenerType] :: SignalInternal? -end - ---[=[ - @private - Fires listeners for the given path. - Takes a bunch of props to make processing less intensive. I want to improve performance for this. -]=] -function TableManager:_FireListeners(props: { - Path: {string}; - ArrayPath: {string}?; - ArrayIndex: number?; - ListenerType: ListenerType; - ListenerContainer: ListenerContainer?; - NewValue: any; - OldValue: any; -}) - - local FIRE_CHILD_LISTENERS = self.FIRE_CHILD_LISTENERS - local FIRE_PARENT_LISTENERS = self.FIRE_PARENT_LISTENERS - - local path = props.Path - local newValue = props.NewValue - local oldValue = props.OldValue - local listenerType = props.ListenerType - local listenerContainer = props.ListenerContainer - - local metadata = table.freeze { - ListenerType = listenerType; - SourcePath = path; - SourceDirection = DataChangeSourceEnum.SelfChanged; - NewValue = newValue; - OldValue = oldValue; - } - - if FIRE_PARENT_LISTENERS then - listenerContainer = self:_FireParentListeners(metadata) - end - - if listenerType ~= ListenerTypeEnum.ValueChanged then - local listenerSignal = self:_GetListenerSignalForPath(listenerType, props.ArrayPath) :: SignalInternal? - if listenerSignal and listenerSignal._head then -- check if atleast one listener is connected - if listenerType == ListenerTypeEnum.ArraySet then - listenerSignal:Fire(props.ArrayIndex, newValue, metadata) - elseif listenerType == ListenerTypeEnum.ArrayInsert then - listenerSignal:Fire(props.ArrayIndex, newValue, metadata) - elseif listenerType == ListenerTypeEnum.ArrayRemove then - listenerSignal:Fire(props.ArrayIndex, oldValue, metadata) - end - else - if self.DEBUG then - warn("No listeners found for", listenerType, listenerSignal, props.ArrayPath, self._Listeners) - end - end - end - - if listenerContainer then -- this will be nil if there are no deeper listeners - --print("Firing Main Listeners", listenerContainer) - local listenerSignal = listenerContainer[ListenerTypeEnum.ValueChanged] :: SignalInternal? - if listenerSignal and listenerSignal._head then -- check if atleast one listener is connected - listenerSignal:Fire(newValue, metadata) - end - - if FIRE_CHILD_LISTENERS then - self:_FireChildListeners(metadata, listenerContainer) - end - else - if self.DEBUG then - warn("No Listener Container found for", path, listenerType, self._Listeners) - end - end - -end - ---[=[ - @private - Fires child listeners for the given path. -]=] -function TableManager:_FireChildListeners(_metadata: ChangeMetadata, _listenerContainer: ListenerContainer) - --print("Firing Child Listeners") - - local metadata = { - ListenerType = _metadata.ListenerType; - SourcePath = _metadata.SourcePath; - SourceDirection = DataChangeSourceEnum.ParentChanged; - NewValue = _metadata.NewValue; - OldValue = _metadata.OldValue; - } - - local function FireChildren(listenerContainer, newSubValue) - local subListeners = listenerContainer.ChildListeners - if subListeners then - for key, subListenerContainer in pairs(subListeners) do -- for any listeners of child keys - local subData = if newSubValue and typeof(newSubValue) == "table" then newSubValue[key] else nil - - local listenerSignal = subListenerContainer[ListenerTypeEnum.ValueChanged] :: SignalInternal? - if listenerSignal and listenerSignal._head then -- check if atleast one listener is connected - listenerSignal:Fire(subData, metadata) - end - - FireChildren(subListenerContainer, subData) - end - end - end - - FireChildren(_listenerContainer, metadata.NewValue) -end - ---[=[ - @private - Fires parent listeners for the given path. -]=] -function TableManager:_FireParentListeners(_metadata: ChangeMetadata): ListenerContainer? - --print("Firing Parent Listeners") - local path = _metadata.SourcePath or {} - local arrayPath = PathToArray(path) - - local listenerContainer = self._Listeners - local parentInfoList = {} - - local parentTable: any, key: string = self, "_Data" - local currentValue = parentTable[key] - - for i = 1, #arrayPath do - key = arrayPath[i] - parentTable = currentValue - currentValue = parentTable[key] - - local valueChangedListenerSignal = listenerContainer[ListenerTypeEnum.ValueChanged] :: SignalInternal? - if valueChangedListenerSignal then - table.insert(parentInfoList, {parentTable, valueChangedListenerSignal}) - end - - local listenersForKey = listenerContainer[ChildListeners][key] - if listenersForKey ~= nil then - listenerContainer = listenersForKey - --print("Found Listener Container for", key) - else - listenerContainer = nil - --print("No listeners found for", key) - break - end - end - - local metadata = { - ListenerType = _metadata.ListenerType; - SourcePath = arrayPath; - SourceDirection = DataChangeSourceEnum.ChildChanged; - NewValue = _metadata.NewValue; - OldValue = _metadata.OldValue; - } - - for _, parentData in parentInfoList do - local parentListenerSignal = parentData[2] :: SignalInternal? - if parentListenerSignal and parentListenerSignal._head then -- check if atleast one listener is connected - parentListenerSignal:Fire( - parentData[1], -- Parent Value Table - metadata - ) - end - end - - return listenerContainer -end - - ---[=[ - @private -]=] -function TableManager:_SetValue(path: Path, newValue: any, lastKey: (string)?) - debug.profilebegin("TM:_SetValue") - - local arrayPath = PathToArray(path or {}) - if lastKey then - arrayPath = table.clone(arrayPath) - table.insert(arrayPath, lastKey) - end - - ------------------------------------------------------- - - local listenerType = ListenerTypeEnum.ValueChanged - - local parentTable: any, key: string = self, "_Data" - local currentValue = parentTable[key] - - local listenerContainer = self._Listeners - - for i = 1, #arrayPath do - key = arrayPath[i] - parentTable = currentValue - currentValue = parentTable[key] - - local listenersForKey = listenerContainer[ChildListeners][key] - if listenersForKey ~= nil then - listenerContainer = listenersForKey - end - end - - ------------------------------------------------------- - local DidChange = false - - if currentValue ~= newValue or typeof(newValue) == "table" then - DidChange = true - - if parentTable == self then - warn("[TableManager] Overwriting value of root table!", self._Data, "->", newValue, if self.DEBUG then "\n"..debug.traceback() else "") - end - - parentTable[key] = newValue -- ! Actual Data Set Point ! - - -- This needs to fire first so that replication happens in the proper order. - self:FireSignal(listenerType, arrayPath, newValue, currentValue) - - self:_FireListeners({ - Path = arrayPath; - ListenerType = listenerType; - ListenerContainer = listenerContainer; - NewValue = newValue; - OldValue = currentValue; - }) - end - - debug.profileend() - return DidChange -end - - -export type TableManager = typeof(TableManager) - -return TableManager \ No newline at end of file diff --git a/lib/tablemanager/src/init.spec.luau b/lib/tablemanager/src/init.spec.luau deleted file mode 100644 index 2f322155..00000000 --- a/lib/tablemanager/src/init.spec.luau +++ /dev/null @@ -1,811 +0,0 @@ --- Authors: Logan Hunt (Raildex), Kaden Fennema --- March 29, 2024 ---[=[ - @class TableManager.spec - @ignore - - This is a test suite for the TableManager class. -]=] - ---// Services //-- -local ReplicatedStorage = game:GetService("ReplicatedStorage") -local RunService = game:GetService("RunService") - ---// Imports //-- -local Packages = script.Parent.Parent - -local ROOT_TABLE_PATH = {} - -local function ArraysMatch(arr1, arr2): boolean - for i, v in ipairs(arr1) do - if v ~= arr2[i] then - return false - end - end - return true -end - --------------------------------------------------------------------------------- - --// Tester //-- --------------------------------------------------------------------------------- - -return function () - -- if RunService:IsRunning() then - -- warn("TableManager.spec is disabled while game is running") - -- return - -- end - - local Janitor = require(Packages.Janitor) - local TableManager = require(script.Parent) - - local tm: TableManager.TableManager - local KEY = "KEY" - local VALUE = "TEST_STRING" - local passed: boolean? - - local KEY1 = "KEY1" - local KEY2 = "KEY2" - local KEY3 = "KEY3" - local PATH1 = {KEY1, KEY2, KEY3} - local PATH2 = {KEY1, KEY2} - - local function MarkAsPassed() - passed = true - end - - beforeEach(function() - tm = TableManager.new() - KEY = "KEY" - passed = nil - end) - - afterEach(function() - tm:Destroy() - end) - - describe("Constructor", function() - - it("should properly create a new TableManager", function() - expect(tm).to.be.ok() - expect(tm).to.be.a("table") - expect(tm:IsA(TableManager)).to.equal(true) - - expect(function() - TableManager.new("Hello") - end).to.throw() - end) - - it("should be equivalent to calling the TableManager class", function() - tm:Destroy() - tm = TableManager { - Value = 5 - } - expect(tm).to.be.ok() - expect(tm).to.be.a("table") - expect(tm:IsA(TableManager)).to.equal(true) - end) - - it("should return the same TableManager if given the same table", function() - local tbl = {} - expect(TableManager.new(tbl)).to.equal(TableManager.new(tbl)) - end) - end) - - describe("Signals", function() - - end) - - describe("Set/Get", function() - beforeEach(function() - tm:SetValue(KEY, VALUE) - end) - - it(`should return the value set to {KEY}`, function() - expect(tm:Get(KEY)).to.equal(VALUE) - expect(tm:Get({KEY})).to.equal(VALUE) - end) - - it("should fetch nested values properly", function() - tm:SetValue(KEY, { - NESTED = VALUE - }) - KEY = "KEY.NESTED" - expect(tm:Get(KEY)).to.equal(VALUE) - expect(tm:Get(string.split(KEY, "."))).to.equal(VALUE) - end) - - it("should return the value for [Instance]", function() - KEY = Instance.new("Part") - tm:SetValue(KEY, VALUE) - expect(tm:Get(KEY)).to.equal(VALUE) - expect(tm:Get({KEY})).to.equal(VALUE) - end) - - it("should work as an alias for Array Get/Set", function() - tm:Set(KEY, {"A", "B", "C"}) - expect(tm:Get(KEY, 1)).to.equal("A") - expect(tm:Get(KEY, 2)).to.equal("B") - - tm:Set(KEY, 2, "D") - expect(tm:Get(KEY, 2)).to.equal("D") - - tm:Set({KEY, 3}, "Z") - expect(tm:Get({KEY, 3})).to.equal("Z") - end) - end) - - describe("Increment", function() - local defaultVal = 0 - beforeEach(function() - tm:SetValue(KEY, defaultVal) - end) - - it("Should increment the value set to KEY", function() - local number = tm:Increment(KEY, 50) - expect(tm:Get(KEY)).to.equal(number) - expect(number).to.equal(50) - - number = tm:Increment(KEY, -100) - expect(tm:Get(KEY)).to.equal(number) - expect(number).to.equal(-50) - end) - - it("Should increment the values in an array", function() - tm:SetValue(KEY, {4, 5, 6}) - tm:Increment(KEY, 1, 10) - tm:Increment(KEY, {2, 3}, 20) - expect(tm:Get(KEY, 1)).to.equal(14) - expect(tm:Get(KEY, 2)).to.equal(25) - expect(tm:Get(KEY, 3)).to.equal(26) - end) - - it("Should increment every value in the array", function() - tm:SetValue(KEY, {4, 5, 6}) - - tm:Increment(KEY, '#', 10) - expect(tm:Get(KEY, 1)).to.equal(14) - expect(tm:Get(KEY, 2)).to.equal(15) - expect(tm:Get(KEY, 3)).to.equal(16) - end) - end) - - describe("Mutate", function() - beforeEach(function() - tm:SetValue(KEY, 0) - end) - - it("Should mutate the data at KEY", function() - local number = tm:Mutate(KEY, function(currentValue) - return currentValue+1 - end) - - expect(tm:Get(KEY)).to.equal(number) - expect(tm:Get({KEY})).to.equal(number) - end) - - it("Should mutate the values in an array", function() - tm:SetValue(KEY, {4, 5, 6}) - tm:Mutate(KEY, {2,3}, function(currentValue) - return currentValue*2 - end) - - expect(tm:Get(KEY, 1)).to.equal(4) - expect(tm:Get(KEY, 2)).to.equal(10) - expect(tm:Get(KEY, 3)).to.equal(12) - end) - end) - - describe("ArrayInsert", function() - beforeEach(function() - tm:SetValue(KEY, {"A", "B", "C"}) - end) - - it("Should insert the value at the specified index and shift", function() - tm:ArrayInsert(KEY, 2, "D") - expect(tm:Get(KEY, 2)).to.equal("D") - expect(tm:Get(KEY, 3)).to.equal("B") - end) - - it("Should insert to the end of the array if an index is not given", function() - tm:ArrayInsert(KEY, "D") - expect(tm:Get(KEY, 4)).to.equal("D") - end) - end) - - describe("ArrayRemove", function() - beforeEach(function() - tm:SetValue(KEY, {"A", "B", "C", "D"}) - end) - - it("Should remove the value at the specified index and shift down values", function() - local removedValue = tm:ArrayRemove(KEY, 3) - expect(removedValue).to.equal("C") - expect(tm:Get(KEY, 3)).to.equal("D") - expect(tm:Get(KEY, 2)).to.equal("B") - end) - - it("Should remove the last value if no index is specified", function() - local removedValue = tm:ArrayRemove(KEY) - expect(removedValue).to.equal("D") - expect(tm:Get(KEY, 3)).to.equal("C") - expect(tm:Get(KEY, 4)).to.equal(nil) - end) - end) - - describe("ArrayRemoveFirstValue", function() - beforeEach(function() - tm:SetValue(KEY, {"D", "A", "B", "A", "C"}) - end) - - it("Should remove the first value and shift down values", function() - local removedIdx = tm:ArrayRemoveFirstValue(KEY, "A") - expect(removedIdx).to.be.ok() - expect(tm:Get(KEY, 1)).to.equal("D") - expect(tm:Get(KEY, 2)).to.equal("B") - expect(tm:Get(KEY, 3)).to.equal("A") - local removedIdx2 = tm:ArrayRemoveFirstValue(KEY, "A") - expect(removedIdx2).to.be.ok() - expect(tm:Get(KEY, 1)).to.equal("D") - expect(tm:Get(KEY, 2)).to.equal("B") - expect(tm:Get(KEY, 3)).to.equal("C") - local removedIdx3 = tm:ArrayRemoveFirstValue(KEY, "A") - expect(removedIdx3).to.equal(nil) - end) - end) - - describe("ToTableState", function() - beforeEach(function() - tm:SetValue(KEY, 100) - end) - it("Should create a TableState and perform the methods as intended", function() - local testPath = KEY - local tableState = tm:ToTableState(testPath) - tableState:Increment(20) - expect(tm:Get(KEY)).to.equal(120) - tableState:Increment(10) - expect(tm:Get(KEY)).to.equal(130) - tableState:Set(200) - expect(tm:Get(KEY)).to.equal(200) - end) - end) - - describe("Observe", function() - beforeEach(function() - tm:SetValue(KEY, 0) - end) - - it("Should fire when the value changes", function() - expect(passed).to.never.be.ok() - tm:Observe(KEY, function(newValue, oldValue) - passed = newValue == 5 and oldValue == 0 - end) - - tm:Set(KEY, 5) - task.wait() - expect(passed).to.be.ok() - end) - - it("Should fire when the value changes due to a parent table changing", function() - tm:SetValue(KEY, { - Child = { - Value = 0, - }, - }) - - expect(passed).to.never.be.ok() - tm:Observe(`{KEY}.Child.Value`, function(newValue, oldValue) - passed = newValue == 5 and oldValue == 0 - end) - - tm:Set(`{KEY}.Child`, { - Value = 5, - }) - task.wait() - expect(passed).to.be.ok() - end) - - it("Should fire when a child value changes", function() - tm:SetValue(KEY, { - Child = { - Value = 0, - }, - }) - - expect(passed).to.never.be.ok() - tm:Observe(`{KEY}.Child`, function(newValue, oldValue) - passed = newValue.Value == 5 and oldValue.Value == 0 - end) - - tm:Set(`{KEY}.Child.Value`, 5) - task.wait() - expect(passed).to.be.ok() - end) - - it("Should not fire if the value is nil or set to nil", function() - passed = true - tm:Set(KEY, nil) - tm:Observe(KEY, function(newValue, _) - if newValue then return end - passed = false - end) - - tm:Set(KEY, 5) - task.wait() - expect(passed).to.be.ok() - tm:Set(KEY, nil) - task.wait() - expect(passed).to.be.ok() - end) - - it("Should fire if the value is nil or set to nil when the flag is true", function() - passed = false - tm:Set(KEY, nil) - tm:Observe(KEY, function(newValue, _) - if newValue ~= nil then return end - passed = true - end, true) - - expect(passed).to.be.ok() - passed = false - tm:Set(KEY, 5) - tm:Set(KEY, nil) - task.wait() - expect(passed).to.be.ok() - end) - - it("Should recieve the last known non nil value for the oldValue", function() - tm:Observe(KEY, function(newValue, oldValue) - passed = newValue == 10 and oldValue == 5 - end) - - tm:Set(KEY, 5) - tm:Set(KEY, nil) - tm:Set(KEY, 10) - task.wait() - expect(passed).to.be.ok() - end) - end) - - describe("SetManyValues", function() - beforeEach(function() - tm:SetValue(KEY, { - Logan = 1, - Kaden = -1, - Marcus = 9_000 - }) - end) - - it("Should setup multiple values in a dict", function() - tm:SetManyValues(KEY, { - Logan = 10, - Kaden = 2 - }) - - expect(ArraysMatch(tm:Get(KEY), { - Logan = 10, - Kaden = 2, - Marcus = 9_000 - })).to.be.ok() - - tm:SetManyValues(KEY, { - Kaden = -50, - Marcus = 1_000 - }) - expect(ArraysMatch(tm:Get(KEY), { - Logan = 10, - Kaden = -50, - Marcus = 1_000 - })).to.be.ok() - end) - end) - - describe("ListenToArraySet", function() - beforeEach(function() - tm:SetValue(KEY, {"A", "B", "C"}) - end) - - it("Should fire when a value is set in the array", function() - tm:ListenToArraySet(KEY, function(index, newValue, oldValue) - passed = index == 2 and newValue == "D" and oldValue == "B" - end) - - expect(passed).to.equal(nil) - tm:ArraySet(KEY, 2, "D") - task.wait() - expect(passed).to.be.ok() - end) - end) - - describe("ListenToArrayInsert", function() - beforeEach(function() - tm:SetValue(KEY, {"A", "B", "C"}) - end) - - it("Should fire when a value is inserted into the array", function() - tm:ListenToArrayInsert(KEY, function(index, value) - passed = index == 2 and value == "D" - end) - - expect(passed).to.equal(nil) - tm:ArrayInsert(KEY, 2, "D") - task.wait() - expect(passed).to.be.ok() - end) - end) - - describe("ListenToArrayRemove", function() - beforeEach(function() - tm:SetValue(KEY, {"A", "B", "C"}) - end) - - it("Should fire when a value is removed from the array", function() - tm:ListenToArrayRemove(KEY, function(index, value) - passed = index == 2 and value == "B" - end) - - expect(passed).to.equal(nil) - tm:ArrayRemove(KEY, 2) - task.wait() - expect(passed).to.be.ok() - end) - end) - - describe("ListenToValueChange", function() - - beforeEach(function() - tm:SetValue(KEY1, { - [KEY2] = { - [KEY3] = 0 - } - }) - end) - - it("Should fire when the value changes", function() - tm:ListenToValueChange(PATH1, function(newValue, oldValue) - passed = newValue - end) - - expect(passed).to.equal(nil) - tm:SetValue(PATH1, 10) - task.wait() - expect(passed).to.equal(10) - tm:Increment(PATH1, 5) - task.wait() - expect(passed).to.equal(15) - end) - - it("Should fire the parent table listeners when the value changes", function() - tm:ListenToValueChange(PATH2, function(newValue, oldValue) - passed = true - end) - - expect(passed).to.equal(nil) - tm:SetValue(PATH1, 5) - task.wait() - expect(passed).to.be.ok() - end) - - it("Should fire the when the value changes due to a parent table changing", function() - tm:ListenToValueChange({KEY1, KEY2, KEY3}, function(newValue, oldValue) - passed = true - end) - - expect(passed).to.equal(nil) - tm:SetValue({KEY1, KEY2}, { - [KEY3] = 5, - Test = 20, - }) - task.wait() - expect(passed).to.be.ok() - end) - end) - - describe("PromiseValue", function() - - it("should resolve when the value meets the criterion", function() - tm:SetValue(KEY, 5) - - passed = false - tm:PromiseValue(KEY, function(value) - return value == 5 - end):now():andThen(function(value) - passed = value == 5 - end) - expect(passed).to.be.ok() - - - passed = nil - tm:PromiseValue(KEY, function(value) - return value == 10 - end):andThen(function(value) - passed = value == 10 - end) - - expect(passed).to.equal(nil) - tm:SetValue(KEY, 15) - task.wait() - expect(passed).to.equal(nil) - tm:SetValue(KEY, 10) - task.wait() - expect(passed).to.be.ok() - end) - - it("should resolve when the value exists", function() - tm:PromiseValue(KEY):andThen(MarkAsPassed) - - expect(passed).to.equal(nil) - tm:SetValue(KEY, 5) - task.wait() - expect(passed).to.be.ok() - end) - - it("should cancel when the tableManager is destroyed", function() - tm:PromiseValue(KEY):finally(MarkAsPassed) - - expect(passed).to.equal(nil) - tm:Destroy() - task.wait() - expect(passed).to.be.ok() - end) - end) - - - - - describe("ListenToKeyChange", function() - - it("should fire when the key changes", function() - tm:ListenToKeyChange(ROOT_TABLE_PATH, function(keyChanged: string, newValue: any, oldValue: any) - passed = keyChanged == KEY and newValue == 5 and oldValue == nil - end) - - expect(passed).to.equal(nil) - tm:SetValue(KEY, 5) - task.wait() - expect(passed).to.be.ok() - end) - - it("should fire when a nested table of a key changes", function() - tm:SetValue(KEY, { - Value = 10 - }) - - tm:ListenToKeyChange(ROOT_TABLE_PATH, function(keyChanged: string, newValue: any, oldValue: any) - MarkAsPassed() - end) - - tm:SetValue(KEY .. ".Value", 5) - task.wait() - expect(passed).to.be.ok() - end) - - it("Should fire when an array key is set", function() - tm:Set(KEY, { - Array = {5, 10, 15} - }) - tm:ListenToKeyChange(ROOT_TABLE_PATH, function(keyChanged: string, newValue: any, oldValue: any) - MarkAsPassed() - end) - - expect(passed).to.never.be.ok() - tm:Set(`{KEY}.Array`, {1,2}, 40) - task.wait() - expect(passed).to.be.ok() - end) - - it("Should fire when an array value is inserted", function() - tm:Set(KEY, { - Array = {5, 10, 15} - }) - tm:ListenToKeyChange(ROOT_TABLE_PATH, function(keyChanged: string, newValue: any, oldValue: any) - MarkAsPassed() - end) - - expect(passed).to.never.be.ok() - tm:ArrayInsert(`{KEY}.Array`, 69) - task.wait() - expect(passed).to.be.ok() - end) - - it("Should fire when an array value is removed", function() - tm:Set(KEY, { - Array = {5, 10, 15} - }) - tm:ListenToKeyChange(ROOT_TABLE_PATH, function(keyChanged: string, newValue: any, oldValue: any) - MarkAsPassed() - end) - - expect(passed).to.never.be.ok() - tm:ArraySet(`{KEY}.Array`, {1,3}, nil) - task.wait() - expect(passed).to.be.ok() - end) - end) - - describe("ListenToNewKey", function() - it("should fire only when a new key is added", function() - tm:ListenToNewKey(ROOT_TABLE_PATH, function(newKey: string, newValue: any) - passed = newKey == KEY and newValue == 5 - end) - - expect(passed).to.equal(nil) - tm:SetValue(KEY, 5) - task.wait() - expect(passed).to.be.ok() - tm:SetValue(KEY, 10) - task.wait() - expect(passed).to.be.ok() - end) - - it("Should fire when a child value changes", function() - tm:Set(KEY, { - Child = {}, - }) - - local listenerKey = `{KEY}.Child` - tm:ListenToNewKey(listenerKey, function(newKey: string, newValue: any) - passed = newKey == listenerKey and newValue.Value == 5 - end) - - expect(passed).to.never.be.ok() - tm:Set(`{KEY}.Child.Value`, 5) - task.wait() - expect(passed).to.be.ok() - end) - - it("Should fire when the parent table changes it's value", function() - tm:Set(KEY, { - Child = {}, - }) - - local listenerKey = `{KEY}.Child` - tm:ListenToNewKey(listenerKey, function(newKey: string, newValue: any) - passed = newKey == listenerKey and newValue.Value == 5 - end) - - expect(passed).to.never.be.ok() - tm:Set(listenerKey, { - Value = 5 - }) - task.wait() - expect(passed).to.be.ok() - end) - end) - - describe("ListenToRemoveKey", function() - - it("Should fire only when a key is removed", function() - tm:SetValue(KEY, 5) - - local connection = tm:ListenToRemoveKey(ROOT_TABLE_PATH, function(removedKey: string, lastValue: any) - passed = removedKey == KEY and lastValue == 5 - end) - - expect(passed).to.equal(nil) - tm:SetValue(KEY, nil) - task.wait() - expect(passed).to.be.ok() - tm:SetValue(KEY, 5) - task.wait() - expect(passed).to.be.ok() - - -- test disconnection - passed = nil - connection:Disconnect() - tm:SetValue(KEY, nil) - task.wait() - expect(passed).to.equal(nil) - end) - - it("Should fire when a key is unset from a parent table change", function() - tm:Set(KEY1, { - [KEY2] = { - [KEY3] = 5 - } - }) - - tm:ListenToRemoveKey({KEY1, KEY2}, function(removedKey: string, lastValue: any) - passed = removedKey == KEY3 and lastValue == 5 - end) - - expect(passed).to.equal(nil) - tm:SetValue({KEY1}, 10) - task.wait() - expect(passed).to.be.ok() - end) - end) - - ------------------------------------------------------------------------------------------------ - - describe("ToFusionState", function() - local Fusion = require(Packages.Fusion) - local peek = Fusion.peek - - local scope = Fusion.scoped({Fusion}) - - beforeEach(function() - tm:SetValue(KEY, 0) - end) - - afterAll(function() - Fusion.doCleanup(scope) - end) - - it("Should return the same fusion state for path", function() - local state1, state2 = tm:ToFusionState(KEY), tm:ToFusionState({KEY}) - expect(state1).to.equal(state2) - expect(peek(state1)).to.equal(peek(state2)) - end) - - it("Should contain the proper value of the key after it has been changed", function() - local state = tm:ToFusionState(KEY) - task.wait() - expect(peek(state)).to.equal(0) - - tm:SetValue(KEY, 50) - task.wait() - expect(peek(state)).to.equal(50) - - tm:Increment(KEY, 100) - task.wait() - expect(peek(state)).to.equal(150) - end) - - it("Should contain the proper value of the key after it has been changed in a nested table", function() - tm:SetValue(KEY, { - Nested = 0 - }) - - local NestedKey = KEY .. ".Nested" - - local state = tm:ToFusionState(NestedKey) - task.wait() - expect(peek(state)).to.equal(0) - - tm:SetValue(NestedKey, 50) - task.wait() - expect(peek(state)).to.equal(50) - - tm:SetValue(KEY, { - Nested = 100 - }) - task.wait() - expect(peek(state)).to.equal(100) - - tm:Increment(NestedKey, 100) - task.wait() - expect(peek(state)).to.equal(200) - end) - - it("Should change when an array change is made", function() - tm:SetValue(KEY, {1, 2, 3}) - - local state = tm:ToFusionState(KEY) - - Fusion.Observer(scope, state):onChange(function() - passed = true - end) - - tm:ArraySet(KEY, 2, 10) - - task.wait() - expect(peek(state)[2]).to.equal(10) - expect(passed).to.be.ok() - - passed = nil - tm:ArrayInsert(KEY, 2, 20) - task.wait() - expect(peek(state)[2]).to.equal(20) - expect(peek(state)[3]).to.equal(10) - expect(peek(state)).to.equal(tm:Get(KEY)) - expect(passed).to.be.ok() - end) - - -- it("Should set the value in TM if we set the value from the state", function() - - -- end) - - end) - - -end diff --git a/lib/tablemanager/src/init.story.luau b/lib/tablemanager/src/init.story.luau deleted file mode 100644 index 2f5eee54..00000000 --- a/lib/tablemanager/src/init.story.luau +++ /dev/null @@ -1,45 +0,0 @@ --- Authors: Logan Hunt (Raildex) --- March 12, 2024 ---[=[ - @class TableManager.story - @ignore - - This is just a class I use to test the TableManager class. -]=] - - ---// Imports //-- -local Class = require(script.Parent) - -return function(target: ScreenGui) - local Object = Class { - Currency = { - Coins = 0, - Gems = 0, - }, - - Inventory = { - Potions = {"Health", "Mana"}, - Equipment = {}, - }, - } - - local thread = task.defer(function() - - Object:ListenToValueChange("Currency.Coins", function(...) - print("Coins changed:", ...) - end) - - while true do - task.wait(1) - Object:Increment("Currency.Coins", 5) - end - - end) - - return function() - task.cancel(thread) - Object:Destroy() - print("Object destroyed") - end -end \ No newline at end of file diff --git a/lib/tablemanager2/src/ArrayDiff.luau b/lib/tablemanager2/src/ArrayDiff.luau new file mode 100644 index 00000000..fa41ca2d --- /dev/null +++ b/lib/tablemanager2/src/ArrayDiff.luau @@ -0,0 +1,190 @@ +--!strict +--[=[ + @ignore + @class ArrayDiff + + Emits `ArrayRemoved` / `ArrayInserted` / `ArraySet` events from an old array to a new one. + + ## Index Contract + + Emitted indices are **replay-faithful**: applying events in order to a copy of `old` + reconstructs `new`. The index reported is the element's position *at the moment that + event is applied*, accounting for prior shifts. + + Consequence: two consecutive removals of the first two elements report `Remove(1) Remove(1)` + — the cursor does not advance on removal. + + ## Usage + + ```lua + local emit: ArrayDiff.Emit = { + removed = function(index, oldValue) ... end, + inserted = function(index, newValue) ... end, + set = function(index, newValue, oldValue) ... end, + } + + ArrayDiff.emitDiff(oldArray, newArray, emit, true) + ``` + + ## `setMode` (fuse gate) + + When `setMode = true`, an adjacent remove+insert at the same live index where + `oldValue ~= newValue` collapses into a single `set` call instead of separate + `removed` + `inserted` calls. Pass `false` to always emit them separately. +]=] + +--// Types //-- + +--[=[ + @within ArrayDiff + @interface MoveMetadata + .moveId string -- Shared id string linking the remove and the insert + .fromIndex number -- Final position of the Remove in the flush sequence + .toIndex number -- Final position of the Insert in the flush sequence + + Optional metadata attached to a remove/insert pair that constitutes a move. + Listeners that do not read this field see a normal remove + insert. +]=] +export type MoveMetadata = { + moveId: string, + fromIndex: number, + toIndex: number, +} + +export type Emit = { + removed: (index: number, oldValue: any, move: MoveMetadata?) -> (), + inserted: (index: number, newValue: any, move: MoveMetadata?) -> (), + set: (index: number, newValue: any, oldValue: any) -> (), +} + +-- Op kinds collected during LCS backtrack, in forward order. +type Op = { + kind: "keep" | "remove" | "insert", + value: any, +} + +-------------------------------------------------------------------------------- +--// Module //-- +-------------------------------------------------------------------------------- + +const ArrayDiff = {} + +--[=[ + @within ArrayDiff + @function buildLCS + + Builds the LCS (Longest Common Subsequence) DP table for `old` and `new`. +]=] +const function buildLCS(old: { any }, new: { any }): { { number } } + const n, m = #old, #new + const dp: { { number } } = table.create(n + 1) :: any + + for i = 0, n do + dp[i + 1] = table.create(m + 1, 0) + end + + for i = 1, n do + const oldVal = old[i] + const rowAbove: { any } = dp[i] + const rowHere: { any } = dp[i + 1] + + for j = 1, m do + if oldVal == new[j] then + rowHere[j + 1] = rowAbove[j] + 1 + else + const up = rowAbove[j + 1] + const left = rowHere[j] + rowHere[j + 1] = if up >= left then up else left + end + end + end + + return dp +end + +--[=[ + @within ArrayDiff + @function backtrack + + Backtracks through the LCS DP table and returns ops in **forward** order. +]=] +const function backtrack(old: { any }, new: { any }, dp: { { number } }): { Op } + -- Walk backward; collect in reverse, then flip. + const reversed: { Op } = {} + local i, j = #old, #new + + while i > 0 or j > 0 do + if i > 0 and j > 0 and old[i] == new[j] then + table.insert(reversed, { kind = "keep" :: "keep", value = old[i] }) + i -= 1 + j -= 1 + elseif j > 0 and (i == 0 or dp[i + 1][j] >= dp[i][j + 1]) then + -- new[j] is an insertion + table.insert(reversed, { kind = "insert" :: "insert", value = new[j] }) + j -= 1 + else + -- old[i] is a removal + table.insert(reversed, { kind = "remove" :: "remove", value = old[i] }) + i -= 1 + end + end + + const fwd: { Op } = table.create(#reversed) :: any + for k = #reversed, 1, -1 do + fwd[#fwd + 1] = reversed[k] + end + + return fwd +end + +--[=[ + @within ArrayDiff + @function emitDiff + + Diffs `old` against `new` and fires `emit` callbacks with replay-faithful indices. + + @param old {any} -- The array before changes. + @param new {any} -- The array after changes. + @param emit Emit -- Callback table: `removed`, `inserted`, `set`. + @param setMode boolean -- When true, fuses adjacent remove+insert at the same live index + (with differing values) into a single `set` call. +]=] +function ArrayDiff.emitDiff(old: { any }, new: { any }, emit: Emit, setMode: boolean) + const dp = buildLCS(old, new) + const ops = backtrack(old, new, dp) + + -- Replay cursor: tracks the live index of the next slot as events are applied. + local cursor = 1 + local k = 1 + const count = #ops + + while k <= count do + const op = ops[k] + + if op.kind == "keep" then + cursor += 1 + k += 1 + elseif op.kind == "remove" then + const nextOp = ops[k + 1] + + -- Fuse gate: remove immediately followed by insert at the SAME live index, + -- with a genuinely different value → collapse into a single ArraySet. + if setMode and nextOp ~= nil and nextOp.kind == "insert" and op.value ~= nextOp.value then + emit.set(cursor, nextOp.value, op.value) + cursor += 1 -- slot stays occupied; advance past it + k += 2 -- consumed both ops + else + emit.removed(cursor, op.value) + -- Cursor does NOT advance: the slot collapsed, the next element + -- shifts down into `cursor`. + k += 1 + end + else -- "insert" + emit.inserted(cursor, op.value) + cursor += 1 + k += 1 + end + end +end + +return ArrayDiff diff --git a/lib/tablemanager2/src/BatchFlush.luau b/lib/tablemanager2/src/BatchFlush.luau new file mode 100644 index 00000000..ebc77dc0 --- /dev/null +++ b/lib/tablemanager2/src/BatchFlush.luau @@ -0,0 +1,365 @@ +--!strict +--[=[ + @ignore + @class BatchFlush + + Owns the Suspend/Resume batch-flush engine for TableManager: while + suspended, writes accumulate into a `BatchState` (tracked array paths, + dirty branch keys, pending prunes). `Resume` then diffs the pre-batch + snapshot against the live data in two phases — a per-branch non-array + diff and a per-tracked-array LCS diff — firing all the resulting change + events. +]=] + +--// Imports //-- +const PathHelpers = require("./PathHelpers") +const BatchUtilsModule = require("./BatchUtils") +const ArrayDiffModule = require("./ArrayDiff") +const CoverageModule = require("./Coverage") +const CoalescedFlushModule = require("./CoalescedFlush") +const TMTypes = require("./TMTypes") + +--// Localize batch utils to avoid function call overhead //-- +const serializeBatchPath = BatchUtilsModule.SerializeBatchPath +const getSnapshotValue = BatchUtilsModule.GetSnapshotValue +const getSnapshotRef = BatchUtilsModule.GetSnapshotRef + +--// Types //-- +type PathArray = PathHelpers.PathArray +type BatchState = BatchUtilsModule.BatchState +type TableManagerLike = TMTypes.TableManagerLike + +const BatchFlush = {} + +--[[ + Returns `branchValue` with every still-existing tracked-array subtree under + `branchKey` replaced by its pre-batch snapshot value, cloning only the + tables along each spine (live data is never mutated; returns `branchValue` + itself when nothing is masked). + + The array flush is the sole owner of tracked-array content changes: it + emits coalesced, shift-faithful Array* events. Leaving those changes in the + non-array branch diff too would deliver them a second time — as in-place + positional entries that are NOT shift-faithful — to any consumer of the + root-listener OriginDiff delivery. + + Arrays that were removed wholesale during the batch are NOT masked (the + array flush skips non-table paths, so their removal must flow through this + branch diff), and arrays created during the batch are already pruned from + TrackedPaths before this runs. +]] +const function maskTrackedArraysForBranchDiff( + self: TableManagerLike, + batch: BatchState, + branchKey: any, + branchValue: any +): any + if type(branchValue) ~= "table" then + return branchValue + end + + -- Deepest-first so a shallower mask planted later wholesale-replaces (and + -- never walks through) a frozen subtree planted by a deeper mask. + const pathsToMask: { { any } } = {} + for _, trackedPath in batch.TrackedPaths do + if trackedPath[1] == branchKey then + table.insert(pathsToMask, trackedPath) + end + end + table.sort(pathsToMask, function(a: { any }, b: { any }) + return #a > #b + end) + + local masked = branchValue + for _, trackedPath in pathsToMask do + -- Only mask while the array still exists as a table. + local liveValue: any = self.Raw + for _, key in trackedPath do + if type(liveValue) ~= "table" then + break + end + liveValue = liveValue[key] + end + if type(liveValue) ~= "table" then + continue + end + + const preBatchValue = getSnapshotValue(batch.StartSnapshot, trackedPath) + if preBatchValue == nil then + continue + end + + if #trackedPath == 1 then + -- The branch itself is the tracked array. + masked = preBatchValue + continue + end + + -- Clone the spine from the branch root down to the array's parent and + -- plant the pre-batch value, so the diff sees this subtree as unchanged. + if masked == branchValue then + masked = table.clone(branchValue) + end + local cursor: any = masked + for i = 2, #trackedPath - 1 do + const key = trackedPath[i] + if type(cursor[key]) ~= "table" then + cursor = nil + break + end + cursor[key] = table.clone(cursor[key]) + cursor = cursor[key] + end + if cursor ~= nil then + cursor[trackedPath[#trackedPath]] = preBatchValue + end + end + + return masked +end + +--[[ + Removes TrackedPaths entries for arrays that did not exist in the pre-batch + snapshot (created during the batch). A newly-created array's container does + not yet exist on a downstream consumer, so coalesced Array* events (which + assume the container exists) cannot be applied. Instead the non-array flush + emits its creation as ordinary key/value adds (the same path a non-batched + creation takes), which build the container and its elements. Removing them + here also stops `BatchFlush.ShouldSuppress` from suppressing those adds. +]] +const function pruneArraysCreatedDuringBatch(batch: BatchState) + for pathKey, path in batch.TrackedPaths do + if getSnapshotRef(batch.StartSnapshot, path) == nil then + batch.TrackedPaths[pathKey] = nil + end + end +end + +--[[ + Resolves the set of top-level root keys whose branch needs a flush: + either every dirty key tracked individually, or (when a "__root__" dirty + marker means a root-level key was written directly) every root key, + pre-batch and current. Shared by `flushNonArrayBranches` and the + post-Resume shadow reconciliation, which both need to iterate the exact + same set. +]] +const function resolveDirtyBranchKeys(self: TableManagerLike, batch: BatchState): { [any]: boolean } + const rootSnapshot = batch.StartSnapshot + const rootSnapshotData: any = if rootSnapshot then rootSnapshot.Data else nil + + local branchKeys: { [any]: boolean } = {} + if batch.DirtyBranches["__root__"] then + if rootSnapshotData and rootSnapshotData.children then + for key in rootSnapshotData.children :: any do + branchKeys[key] = true + end + end + for key in self.Raw :: any do + branchKeys[key] = true + end + else + for branchKey in batch.DirtyBranches do + branchKeys[branchKey] = true + end + end + return branchKeys +end + +--[[ + Non-array flush: diff only the branches that were actually mutated during + the batch. This avoids traversing the whole table when only a small subset + of keys changed. For each dirty branch key we extract the pre-batch value + from the root snapshot's Diff.Snapshot children and compare it against the + current live value, letting ChangeDetector fire all leaf + ancestor events. + + Tracked-array subtrees are MASKED out of each branch diff (see + maskTrackedArraysForBranchDiff): flushTrackedArrays is the sole owner of + their content changes, so the branch diff must not also carry them. +]] +const function flushNonArrayBranches(self: TableManagerLike, batch: BatchState, branchKeys: { [any]: boolean }) + if not batch.StartSnapshot then + return + end + + const rootSnapshot = batch.StartSnapshot + const rootSnapshotData: any = rootSnapshot.Data -- Diff.Snapshot + + for branchKey in branchKeys do + -- Extract old branch value from the pre-batch snapshot's children map. + const oldBranchValue: any = if rootSnapshotData and rootSnapshotData.children + then (rootSnapshotData.children :: any)[branchKey] and (rootSnapshotData.children :: any)[branchKey].value or nil + else nil + + -- Current live value for this branch, with still-existing tracked + -- arrays substituted by their pre-batch values (no change to report). + const newBranchValue: any = maskTrackedArraysForBranchDiff(self, batch, branchKey, (self.Raw :: any)[branchKey]) + + self._changeDetector:CheckForChangesBetween(oldBranchValue, newBranchValue, { branchKey }, self.Raw) + end +end + +--[[ + Array flush: for each tracked array path, diff the pre-batch snapshot value + against the current value via LCS (`ArrayDiff.emitDiff`), emitting coalesced + Array* events. Arrays removed wholesale during the batch are skipped here — + their removal flows through the non-array branch diff instead. +]] +const function flushTrackedArrays(self: TableManagerLike, batch: BatchState) + const makeEmit = + require("./Emitter").makeEmit :: (self: TableManagerLike, path: PathHelpers.PathArray) -> ArrayDiffModule.Emit + for _, path in batch.TrackedPaths do + const currentArray = self:Get(path) + if type(currentArray) ~= "table" then + continue + end + + const oldArray: { any } = getSnapshotValue(batch.StartSnapshot, path) or {} + ArrayDiffModule.emitDiff(oldArray, currentArray, makeEmit(self, path), true) + end +end + +--[[ + Brings the persistent shadow (see Shadow.luau / TableManager:Flush) up to + date for every branch/array touched by this batch, so the NEXT immediate + (non-batched) flush anywhere in this batch's touched regions diffs against + today's post-batch state instead of a stale pre-batch one. Only the two + per-op flush phases above actually fired events for this batch — this + pass does no diffing/firing of its own, just `Materialize`s whichever + touched paths are still trackable now that the batch is done. +]] +const function reconcileShadowAfterBatch(self: TableManagerLike, batch: BatchState, branchKeys: { [any]: boolean }) + const ctx = self._changeDetector:GetOpaqueCtx() + -- Skipped through an opaque ancestor (see Coverage.PassesThroughOpaqueAncestor) + -- since Materialize can't safely persist there. + for branchKey in branchKeys do + const path = { branchKey } + if + CoverageModule.IsTrackable(self, path) and not CoverageModule.PassesThroughOpaqueAncestor(self, path, ctx) + then + self._shadow:Materialize(path, self.Raw, ctx) + end + end + for _, path in batch.TrackedPaths do + if + CoverageModule.IsTrackable(self, path) and not CoverageModule.PassesThroughOpaqueAncestor(self, path, ctx) + then + self._shadow:Materialize(path, self.Raw, ctx) + end + end +end + +--[=[ + Suspends all signal and listener firing. + + Pair with `Resume()`. Nested calls are no-ops (the outermost window wins). +]=] +function BatchFlush.Suspend(self: TableManagerLike) + if self._batchDepth > 0 then + return -- Already suspended; nested Suspend is a no-op + end + -- Drain any outstanding coalesced flush BEFORE capturing the pre-batch + -- snapshot below. The snapshot is read directly off live data; if a + -- coalesced flush were still pending, that read would silently become the + -- new baseline and the transition the pending flush was holding (shadow + -- vs. the live value just before THIS snapshot) would never reach any + -- listener — the pending flush later no-ops once the batch's own Resume + -- has already reconciled the shadow past it. Flushing first makes the + -- shadow authoritative again, so the snapshot below picks up cleanly from + -- it. + CoalescedFlushModule.FlushPending(self) + -- Capture the pre-batch snapshot BEFORE suspending ChangeDetector so that + -- CheckForChangesBetween at flush time can diff old-vs-current correctly. + -- `CaptureSnapshot` inside `ChangeDetector` returns a sentinel (O(1)) so no + -- snapshot/diff work is done during the window. Tracked array paths are + -- diffed against this snapshot via LCS at flush time. + self._batch = { + StartSnapshot = self._changeDetector:CaptureSnapshot(self.Raw, {}), + TrackedPaths = {}, + DirtyBranches = {}, + PendingPrunes = {}, + Flushing = false, + } + self._batchDepth = 1 + self._changeDetector:Suspend() +end + +--[=[ + Resumes after `Suspend()` and flushes all pending changes. + + Flush is two-phase: + 1. **Non-array flush** — per dirty branch, `CheckForChangesBetween` diffs the + pre-batch value against the current value (with tracked-array subtrees + masked out — the array flush owns those), firing all non-array events. + 2. **Array flush** — For each tracked array path, diffs the pre-batch + snapshot against the current value via LCS (`ArrayDiff.emitDiff`). +]=] +function BatchFlush.Resume(self: TableManagerLike) + if self._batchDepth == 0 then + return -- Not suspended + end + + -- Re-enable ChangeDetector before the flush so CheckForChangesBetween works normally. + self._changeDetector:Resume() + const batch = self._batch + if not batch then + self._batchDepth = 0 + return + end + batch.Flushing = true + + pruneArraysCreatedDuringBatch(batch) + const branchKeys = resolveDirtyBranchKeys(self, batch) + flushNonArrayBranches(self, batch, branchKeys) + flushTrackedArrays(self, batch) + reconcileShadowAfterBatch(self, batch, branchKeys) + + -- Clear batch state + batch.Flushing = false + self._batchDepth = 0 + self._batch = nil + + -- Prune proxy bookkeeping for values detached during the batch. Done after + -- the flush (and after _batch is cleared, so a re-entrant write from a flush + -- listener that detached a table prunes immediately rather than queueing + -- into a dead batch). Values re-homed during the batch are spared by + -- PruneOriginal's liveness guard. + if self._proxyManager then + for _, removedValue in batch.PendingPrunes do + self._proxyManager:PruneOriginal(removedValue) + end + end +end + +-- During batch array flush: suppress any change event whose location is at or +-- under a tracked array path. Those arrays emit their own coalesced Array* +-- events in the array flush phase, so the non-array flush firing here too would +-- double-apply the change for consumers (e.g. replication). This covers the +-- array container, its numeric element slots, AND string-keyed fields of array +-- elements (e.g. {"items", 1, "hp"}), which a numeric-only check would miss. +-- Arrays created during the batch are pruned from TrackedPaths before the flush +-- (see Resume) so their creation flows through the non-array flush instead. +function BatchFlush.ShouldSuppress(self: TableManagerLike, location: PathArray): boolean + const batch = self._batch + if batch == nil or not batch.Flushing then + return false + end + const tracked = batch.TrackedPaths + for i = 1, #location do + const prefix = table.create(i) + for j = 1, i do + prefix[j] = location[j] + end + if tracked[serializeBatchPath(prefix)] ~= nil then + -- Only suppress when the tracked array STILL EXISTS, because only + -- then does the array flush re-emit its contents. If the array was + -- removed wholesale during the batch, the array flush skips it, so its + -- removal must be allowed through this (non-array) flush instead. + if type(self:Get(prefix, true)) == "table" then + return true + end + end + end + return false +end + +return BatchFlush diff --git a/lib/tablemanager2/src/BatchUtils.luau b/lib/tablemanager2/src/BatchUtils.luau new file mode 100644 index 00000000..b8ab66e2 --- /dev/null +++ b/lib/tablemanager2/src/BatchUtils.luau @@ -0,0 +1,101 @@ +--!strict +--[=[ + @ignore + @class BatchUtils + + Internal utilities for batch operation tracking and snapshot management. + These functions handle serialization, state management, and snapshot navigation + for TableManager's batch system. +]=] + +--// Imports //-- +const ChangeDetector = require("./ChangeDetector") + +--// Types //-- +type PathArray = { any } + +export type BatchState = { + StartSnapshot: ChangeDetector.Snapshot, + TrackedPaths: { [string]: PathArray }, + DirtyBranches: { [any]: boolean }, + -- Table values detached during the batch (overwritten/removed). Proxy-graph + -- pruning is deferred to the end of Resume so values re-homed later in the + -- same batch (e.g. by Swap) are spared by PruneOriginal's liveness guard. + PendingPrunes: { any }, + Flushing: boolean, +} + +const BatchUtils = {} + +-- Creates the ChangeMetadata.Snapshot context for array operations and +-- ForceNotify, which bypass normal ChangeDetector flow. Only RootTable is +-- ever read (ancestor value navigation), so no deep copy is taken. +function BatchUtils.CreateAncestorSnapshot(rootTable: T): ChangeDetector.AncestorSnapshot + return { + RootTable = rootTable, + } +end + +-- Serializes a path to a string key for batch tracking. +function BatchUtils.SerializeBatchPath(path: { any }): string + if #path == 0 then + return "__root__" + end + local parts = table.create(#path) + for i, seg in path do + parts[i] = tostring(seg) + end + return table.concat(parts, "\0") +end + +-- Navigates a ChangeDetector Snapshot's Diff.Snapshot children and returns +-- the deep-copied value stored at `path` (i.e., the pre-batch state). +function BatchUtils.GetSnapshotValue(snapshot: ChangeDetector.Snapshot, path: { any }): any? + local snap: any = snapshot.Data + for _, key in path do + if not snap or not snap.children then + return nil + end + snap = (snap.children :: any)[key] + end + return snap and snap.value or nil +end + +-- Like GetSnapshotValue but returns the ORIGINAL table reference captured at +-- `path` pre-batch (Diff.Snapshot.ref), not the frozen copy. Used by +-- pruneArraysCreatedDuringBatch to detect arrays created during the batch +-- (no pre-batch ref). +function BatchUtils.GetSnapshotRef(snapshot: ChangeDetector.Snapshot, path: { any }): any? + local snap: any = snapshot.Data + for _, key in path do + if not snap or not snap.children then + return nil + end + snap = (snap.children :: any)[key] + end + return snap and snap.ref or nil +end + +-- Gets the top-level key (first segment) for a path, used for batch branch tracking. +function BatchUtils.GetBatchBranchKey(path: { any }): any + return if #path > 0 then path[1] else "__root__" +end + +-- Marks a branch as having changes during a batch operation. +function BatchUtils.MarkBatchBranchDirty(batch: BatchState?, path: { any }) + if batch then + batch.DirtyBranches[BatchUtils.GetBatchBranchKey(path)] = true + end +end + +-- Ensures a path is being tracked in the batch for the flush's array diff. +function BatchUtils.EnsureBatchPathTracking(batch: BatchState, path: { any }) + local pathKey = BatchUtils.SerializeBatchPath(path) + if batch.TrackedPaths[pathKey] then + return + end + + batch.TrackedPaths[pathKey] = table.clone(path) +end + +return BatchUtils diff --git a/lib/tablemanager2/src/ChangeDetector.luau b/lib/tablemanager2/src/ChangeDetector.luau new file mode 100644 index 00000000..685253a8 --- /dev/null +++ b/lib/tablemanager2/src/ChangeDetector.luau @@ -0,0 +1,725 @@ +--!strict +--[=[ + @class ChangeDetector + @ignore + + Detects and reports nested table/value changes using snapshot-based diffing. + + ## Workflow + Direct compare (one-off): + ```lua + detector:CheckForChangesBetween(oldValue, newValue, {"player", "stats"}) + ``` + + `CaptureSnapshot` is also available as a standalone baseline primitive for + callers (e.g. `TableManager`'s batch flush) that need to diff a captured + pre-batch value against the live value later via `CheckForChangesBetween`. + + ## Change Types + The detector handles table/scalar/nil transitions, including root-level changes. + + ## Callback Semantics + - Leaf callbacks include `metadata.Diff` with the exact changed node. + - Ancestor callbacks use `metadata.Diff = nil` and fire once per assignment operation. + - `metadata.OriginPath` always points to the assignment origin (captured path), not each leaf. +]=] + +local Diff = require("./Diff") +local PathHelpers = require("./PathHelpers") +local ArrayDiffModule = require("./ArrayDiff") +local OpaqueRegistry = require("./OpaqueRegistry") +local IgnoreTrieModule = require("./IgnoreTrie") + +--// Types //-- +type Path = PathHelpers.Path +type PathArray = PathHelpers.PathArray +type MoveMetadata = ArrayDiffModule.MoveMetadata + +--[=[ + @within ChangeDetector + @interface Snapshot + .RootTable { [any]: any } -- Reference to the root table being tracked (for ancestor navigation) + .Path PathArray -- The path where the snapshot was captured (e.g., {"Player", "Stats"}) + .Data Diff.Snapshot -- The Diff.Snapshot of the value at the captured path + .Timestamp number -- Capture timestamp (debugging/ordering) + + A baseline captured at a path (e.g. at batch start), later diffed against + a live value via `CheckForChangesBetween`. +]=] +export type Snapshot = { + RootTable: T, + Path: PathArray, + Data: Diff.Snapshot, + Timestamp: number, +} + +--[=[ + @within TableManager + @interface AncestorSnapshot + .RootTable { [any]: any } -- Reference to the root table, for ancestor value navigation. + + The lightweight context handed to callbacks via `ChangeMetadata.Snapshot`. + Unlike `Snapshot`, this carries no captured baseline — it exists solely so a + callback can navigate to live ancestor values via `RootTable`. +]=] +export type AncestorSnapshot = { + RootTable: T, +} + +export type ChangeDetector = { + CaptureSnapshot: (self: ChangeDetector, rootTable: T, path: PathArray) -> Snapshot, + CheckForChangesBetween: ( + self: ChangeDetector, + oldValue: any, + newValue: any, + basePath: PathArray, + rootTable: T? + ) -> (), + --- Dispatches an ALREADY-BUILT root diff node at `basePath` (fires the same + --- leaf + ancestor callbacks as `CheckForChangesBetween`) WITHOUT running + --- `Diff.diff`. Used by linked fan-out to reuse the origin's diff node + --- instead of re-diffing the same old/new (see `TableManager._NotifyApplied`). + DispatchDiffNode: ( + self: ChangeDetector, + node: Diff.DiffNode, + basePath: PathArray, + rootTable: T + ) -> (), + --- Suspends change detection. While suspended, `CaptureSnapshot` is a + --- cheap O(1) no-op. Used by `TableManager:Batch()`. + Suspend: (self: ChangeDetector) -> (), + --- Resumes change detection after a `Suspend` call. + Resume: (self: ChangeDetector) -> (), + --- Returns the opacity oracle to pass to `Diff`, or `nil` when nothing is + --- marked opaque (per-manager or global) — lets other modules building + --- their own `Diff.diff`/`Diff.snapshot` calls pass the same opacity rules. + GetOpaqueCtx: (self: ChangeDetector) -> Diff.Ctx?, +} + +--[=[ + @within TableManager + @interface ChangeMetadata + .Diff Diff.DiffNode -- Diff node for this callback level; nil for ancestor notifications. + .OriginPath Path -- The path where the assignment operation occurred (captured path) + .OriginDiff Diff.DiffNode -- Root diff node for the assignment operation. + .Snapshot AncestorSnapshot -- Carries RootTable for ancestor value navigation. + + `OriginPath` is the assignment origin for both leaf and ancestor callbacks. +]=] +export type ChangeMetadata = { + Diff: Diff.DiffNode?, + OriginPath: PathArray, + OriginDiff: Diff.DiffNode, + Snapshot: AncestorSnapshot, + -- The literal keys matched by each "*" segment of the listener's registered + -- path, in left-to-right order. Set by ListenerRegistry; nil if the + -- listener path had no wildcards. + WildcardMatches: { any }?, + -- Set on the ArrayRemoved/ArrayInserted pair from a non-batched + -- ArraySwapRemove that constitutes a move. nil otherwise. + Move: MoveMetadata?, + -- Set on synthetic array-operation events (ArrayInserted/ArrayRemoved/ + -- ArraySet), including their ancestor notifications. Carries explicit shift + -- semantics for consumers that apply the OriginDiff tree: a flattened + -- "removed" entry at a numeric leaf cannot otherwise be distinguished from + -- an in-place nil write (a hole), nor "added" from a non-shifting index + -- write. ArrayInserted/ArrayRemoved shift later elements; ArraySet does not. + ArrayOp: { Kind: "ArrayInserted" | "ArrayRemoved" | "ArraySet", Index: number }?, +} + +-------------------------------------------------------------------------------- +--// Module //-- +-------------------------------------------------------------------------------- + +local ChangeDetector = {} +local ChangeDetector_MT = { __index = ChangeDetector } + +-------------------------------------------------------------------------------- +--// Constructor //-- +-------------------------------------------------------------------------------- + +--[=[ + Creates a new ChangeDetector instance. + + @param callbacks -- Callbacks for different change events + @param debugMode -- Optional flag to enable debug logging + @param registries -- Optional per-manager opacity registries (see `OpaqueRegistry`) + @return ChangeDetector +]=] +function ChangeDetector.new( + callbacks: { + OnKeyAdded: (path: PathArray, key: any, newValue: any, metadata: ChangeMetadata) -> ()?, + OnKeyRemoved: (path: PathArray, key: any, oldValue: any, metadata: ChangeMetadata) -> ()?, + OnKeyChanged: (path: PathArray, key: any, newValue: any, oldValue: any, metadata: ChangeMetadata) -> ()?, + OnValueChanged: (path: PathArray, newValue: any, oldValue: any?, metadata: ChangeMetadata) -> ()?, + }, + debugMode: boolean?, + registries: OpaqueRegistry.Registries?, + ignoreTrie: IgnoreTrieModule.Node? +): ChangeDetector + callbacks.OnKeyAdded = callbacks.OnKeyAdded or function() end + callbacks.OnKeyRemoved = callbacks.OnKeyRemoved or function() end + callbacks.OnKeyChanged = callbacks.OnKeyChanged or function() end + callbacks.OnValueChanged = callbacks.OnValueChanged or function() end + + local self = setmetatable({ + _callbacks = callbacks, + _debugMode = debugMode or false, + _suspended = false, + -- Sentinel snapshot: a fixed table that CaptureSnapshot returns when + -- suspended, so callers reading it via BatchUtils.GetSnapshotValue/ + -- GetSnapshotRef get nil instead of paying for a real capture. + _sentinelSnapshot = {} :: any, + -- Re-entrancy guard: while a check is dispatching callbacks, checks + -- triggered from inside those callbacks are queued and run afterwards. + _dispatching = false, + _dispatchQueue = {} :: { () -> () }, + -- Opacity: registries is nil for managers built before this existed in + -- tests that construct ChangeDetector directly; `_oracle` treats that + -- the same as "nothing marked" (ctx == nil, zero-cost original path). + _registries = registries, + -- Lazily-built once activated (see `_oracle`); never rebuilt afterwards + -- since the closures close over the (mutable) registry tables. + _opaqueCtx = nil :: Diff.Ctx?, + -- Ignored-path pruning: the root trie node (a mutable table), so later + -- `SetPathIgnored` calls are visible without re-handing it in. nil is + -- the same as an empty trie (nothing pruned). + _ignoreTrie = ignoreTrie, + }, ChangeDetector_MT) :: any + + return self :: ChangeDetector +end + +--[[ + Returns the opacity oracle when anything is marked opaque (per-manager or + global), or `nil` otherwise. `nil` means callers should pass `ctx = nil` + through to `Diff`, hitting the exact original (no per-node probe) path. +]] +function ChangeDetector:_oracle(): Diff.Ctx? + local registries = self._registries + if registries == nil then + return nil + end + if registries.Count == 0 and OpaqueRegistry.GlobalCount() == 0 then + return nil + end + if self._opaqueCtx == nil then + self._opaqueCtx = OpaqueRegistry.MakeCtx(registries) + end + return self._opaqueCtx +end + +function ChangeDetector:GetOpaqueCtx(): Diff.Ctx? + return self:_oracle() +end + +-------------------------------------------------------------------------------- +--// Public Methods //-- +-------------------------------------------------------------------------------- + +--[=[ + Captures a snapshot of the table at the specified path and returns a snapshot object. + The snapshot's `.Data` can later be read (e.g. via `BatchUtils.GetSnapshotValue`) + or diffed against a live value via `CheckForChangesBetween`. + + @param rootTable -- The root table to track changes on + @param path -- The path to the value to snapshot (e.g., {"player", "stats"}) + @return Snapshot + + ```lua + local gameState = { player = { health = 50 } } + local snapshot = detector:CaptureSnapshot(gameState, {"player"}) + local oldPlayer = snapshot.Data.value + gameState.player.health = 100 + detector:CheckForChangesBetween(oldPlayer, gameState.player, {"player"}, gameState) + ``` +]=] +function ChangeDetector:CaptureSnapshot(rootTable: { [any]: any }, path: PathArray): Snapshot + -- While suspended, skip all snapshot work and return the sentinel. + if self._suspended then + return self._sentinelSnapshot + end + + if type(rootTable) ~= "table" then + error("CaptureSnapshot expects rootTable to be a plain table.") + end + + if self._debugMode then + print("CaptureSnapshot called:") + print(" path:", table.concat(path, ".")) + print(" rootTable type:", type(rootTable)) + end + + -- Navigate to the value at the path + local valueAtPath = rootTable + for _, key in ipairs(path) do + if valueAtPath == nil then + error(`Failed to reach key: {key}, Path {table.concat(path, ".")} does not exist in table`) + end + valueAtPath = valueAtPath[key] + end + + -- Create snapshot using Diff.snapshot (includes reference tracking) + local snapshot: Snapshot = { + RootTable = rootTable, -- Keep reference to root (not copied) + Path = table.clone(path), -- Clone the path array + Data = Diff.snapshot(valueAtPath, self:_oracle()), -- Diff.Snapshot with ref tracking + Timestamp = os.clock(), + } + + return snapshot +end + +--[=[ + Directly compares two values and detects changes without requiring a snapshot. + + @param oldValue -- The old value (before change) + @param newValue -- The new value (after change) + @param basePath -- The path to this value in the table hierarchy + + ```lua + local detector = ChangeDetector.new({ + OnKeyChanged = function(path, key, newValue, oldValue, metadata) + print("Key", key, "changed at", table.concat(path, ".")) + print(" Old:", oldValue, "-> New:", newValue) + print(" Type:", metadata.Diff and metadata.Diff.type) + end, + OnKeyAdded = function(path, key, newValue, metadata) + print("Key", key, "added at", table.concat(path, ".")) + print(" Value:", newValue) + print(" Type:", metadata.Diff and metadata.Diff.type) + end, + }) + + local oldTable = { + config = { + host = "localhost", + port = 8080 + } + } + local newTable = { + config = { + host = "example.com", + port = 8080, + ssl = true + } + } + + -- Direct comparison without snapshot + detector:CheckForChangesBetween(oldTable, newTable, {"server"}) + + -- Output: + -- Key host changed at server.config.host + -- Old: localhost -> New: example.com + -- Key ssl added at server.config.ssl + -- Value: true + ``` +]=] +function ChangeDetector:CheckForChangesBetween( + oldValue: any, + newValue: any, + basePath: PathArray, + rootTable: { [any]: any }? +) + if self._debugMode then + print("CheckForChangesBetween called:") + print(" basePath:", table.concat(basePath, ".")) + print(" oldValue type:", type(oldValue)) + print(" newValue type:", type(newValue)) + end + + -- If no real root table is provided, synthesize one so ancestor callbacks + -- can still navigate to values at `basePath`. + local tempRootTable: { [any]: any } + if rootTable then + tempRootTable = rootTable + else + warn( + "No Root Table Provided. Ancestor callbacks will receive a synthetic root with the changed value at the base path." + ) + if #basePath == 0 then + tempRootTable = if type(newValue) == "table" then newValue else { __value = newValue } + else + tempRootTable = {} + local cursor: any = tempRootTable + for i = 1, #basePath - 1 do + local segment = basePath[i] + cursor[segment] = {} + cursor = cursor[segment] + end + cursor[basePath[#basePath]] = newValue + end + end + local ctx = self:_oracle() + -- No deep snapshot of oldValue here: Diff.diff below compares oldValue/ + -- newValue directly (snap1/snap2 = nil), so the only thing callbacks need + -- from this object is RootTable for their own ancestor navigation. + local ancestorSnapshot: AncestorSnapshot = { + RootTable = tempRootTable, + } + + -- Diff.diff stays INSIDE the dispatch closure so a re-entrant check queued + -- by _dispatch diffs against state at DRAIN time (see _dispatch's note on + -- collapsing chained re-entrant writes). + self:_dispatch(function() + local rootDiffNode = Diff.diff(oldValue, newValue, nil, nil, ctx) + self:_emitRootNode(rootDiffNode, basePath, ancestorSnapshot) + end) +end + +--[=[ + Dispatches an already-built `node` at `basePath` against `rootTable` — fires + the exact same leaf + ancestor callbacks as `CheckForChangesBetween`, just + without re-running `Diff.diff`. The node is immutable here (there is nothing + to re-diff at drain time), so it is simply emitted through the same + re-entrancy-ordered dispatch queue. + + Used by linked fan-out (`TableManager._NotifyApplied`) to replay the ORIGIN's + diff node on a recipient instead of recomputing the identical diff. +]=] +function ChangeDetector:DispatchDiffNode(node: Diff.DiffNode, basePath: PathArray, rootTable: { [any]: any }) + local ancestorSnapshot: AncestorSnapshot = { + RootTable = rootTable, + } + self:_dispatch(function() + self:_emitRootNode(node, basePath, ancestorSnapshot) + end) +end + +--[=[ + Suspends change detection. + + While suspended, `CaptureSnapshot` returns a cheap sentinel value instead of + performing a full snapshot. This means every assignment through + `ProxyManager.__newindex` costs O(1) instead of a full snapshot cycle. + + Used by `TableManager:Suspend()` / `TableManager:Batch()`. Always pair with + a matching `Resume()` call. +]=] +function ChangeDetector:Suspend() + self._suspended = true +end + +--[=[ + Resumes change detection after a `Suspend` call. +]=] +function ChangeDetector:Resume() + self._suspended = false +end + +-------------------------------------------------------------------------------- +--// Private Methods //-- +-------------------------------------------------------------------------------- + +--[[ + Runs `job` now, or queues it when a check is already dispatching callbacks + (a re-entrant check triggered by a write from inside a listener). Queued + jobs run after the current dispatch fully completes, so the event stream + for the OUTER write is always delivered before the events of any write it + triggered. Without this, a listener writing back during notification (e.g. + a clamp) emits its nested events FIRST, and a consumer applying events in + receipt order ends on the pre-clamp value. + + Note: a queued check diffs against the state at DRAIN time, so chained + re-entrant writes may collapse into fewer events; the net result is + unchanged. +]] +function ChangeDetector:_dispatch(job: () -> ()) + if self._dispatching then + table.insert(self._dispatchQueue, job) + return + end + + self._dispatching = true + local ok, err = pcall(function() + job() + while #self._dispatchQueue > 0 do + local queued = table.remove(self._dispatchQueue, 1) :: () -> () + queued() + end + end) + self._dispatching = false + if not ok then + table.clear(self._dispatchQueue) + error(err, 3) + end +end + +--[[ + Helper to fire callbacks for a single node in the diff tree. + Encapsulates the common callback firing logic to reduce duplication. +]] +local function fireNodeCallbacks( + callbacks: any, + basePath: Path, + nodePath: Path, + nodeKey: any?, + diffNode: Diff.DiffNode, + metadata: ChangeMetadata +) + local OnKeyAdded = callbacks.OnKeyAdded + local OnKeyRemoved = callbacks.OnKeyRemoved + local OnKeyChanged = callbacks.OnKeyChanged + local OnValueChanged = callbacks.OnValueChanged + + if diffNode.type == "changed" or diffNode.type == "descendantChanged" then + -- Only fire OnKeyChanged if there's actually a key (not at root level) + if nodeKey ~= nil then + OnKeyChanged(basePath, nodeKey, diffNode.new, diffNode.old, metadata) + end + OnValueChanged(nodePath, diffNode.new, diffNode.old, metadata) + elseif diffNode.type == "added" then + -- Only fire OnKeyAdded if there's actually a key (not at root level) + if nodeKey ~= nil then + OnKeyAdded(basePath, nodeKey, diffNode.new, metadata) + end + OnValueChanged(nodePath, diffNode.new, nil, metadata) + elseif diffNode.type == "removed" then + -- Only fire OnKeyRemoved if there's actually a key (not at root level) + if nodeKey ~= nil then + OnKeyRemoved(basePath, nodeKey, diffNode.old, metadata) + end + OnValueChanged(nodePath, nil, diffNode.old, metadata) + end +end + +--[[ + Shared tail of `CheckForChangesBetween` and `DispatchDiffNode`: given an + already-built root diff `node` for `basePath`, runs the DFS leaf callbacks + and the base-level ancestor callbacks. No-op when `node` is nil (no changes). + Must be called inside a `_dispatch` closure. +]] +function ChangeDetector:_emitRootNode(node: Diff.DiffNode?, basePath: PathArray, ancestorSnapshot: AncestorSnapshot) + if not node then + return + end + + local rootParentPath: Path = {} + local rootKey: any? = nil + if #basePath > 0 then + rootParentPath = table.create(#basePath - 1) + table.move(basePath, 1, #basePath - 1, 1, rootParentPath) + rootKey = basePath[#basePath] + end + + -- Process all leaf changes via DFS; basePath is both the current path and + -- the origin. Fire ancestor callbacks for the base level afterward. + self:_processDiffNode(node, basePath, rootParentPath, rootKey, basePath, node, ancestorSnapshot) + self:_fireAncestorCallbacks(basePath, node, ancestorSnapshot) +end + +--[=[ + DFS traversal for diff nodes. + Fires callbacks for each node with origin metadata from the root assignment. +]=] +function ChangeDetector:_processDiffNode( + node: Diff.DiffNode, + nodePath: PathArray, + parentPath: PathArray, + nodeKey: any?, + originPath: PathArray, + originDiff: Diff.DiffNode, + snapshot: AncestorSnapshot +) + -- Ignored-path pruning: skip this node AND its descendants entirely (no + -- recursion, no callbacks). `nodePath` is already the absolute path, so + -- this is the one place a path-ignore mark can intercept changes that + -- arrive nested inside a larger write (e.g. a root replacement) — the + -- write's own exact path is instead pre-pruned in `Mutator.applyWrite`, + -- before any snapshot/diff work happens. + if IgnoreTrieModule.IsPathIgnored(self._ignoreTrie, nodePath) then + return + end + + local callbacks = self._callbacks + local debugMode = self._debugMode + + if debugMode then + print(`Processing diff node at path: {table.concat(nodePath, ".")}, type: {node.type}`) + end + + -- Recurse into children if present. The scalar-sentinel child (the scalar + -- side of a table<->scalar transition) is processed LAST: it represents + -- this node's own replacement value, so consumers must receive the + -- subtree's teardown events (e.g. removed {b,v}) BEFORE the event that + -- collapses the node to a scalar ({b}=5). pairs() order would otherwise + -- make this nondeterministic, and a consumer that applied the scalar first + -- would then try to apply child events through a non-table value. + if node.children then + local sentinelChild: Diff.DiffNode? = nil + for key, childNode in node.children do + if key == Diff.ScalarSentinel then + sentinelChild = childNode + continue + end + local childPath = PathHelpers.Append(nodePath, key) + + -- Pass the same origin path, origin diff, and snapshot down through recursion + self:_processDiffNode(childNode, childPath, nodePath, key, originPath, originDiff, snapshot) + end + + if sentinelChild then + -- The sentinel child represents a scalar replacement at this exact + -- node, so it keeps the same nodePath and key as its parent node. + self:_processDiffNode( + sentinelChild, + table.clone(nodePath), + parentPath, + nodeKey, + originPath, + originDiff, + snapshot + ) + end + end + + -- Create metadata for this node + -- OriginPath is the captured path (where the assignment occurred) + -- OriginDiff is the root diff (the entire operation) + -- Snapshot provides context for ancestor value navigation + local metadata: ChangeMetadata = { + Diff = node, + OriginPath = originPath, -- Where the assignment happened + OriginDiff = originDiff, -- The root diff of the entire operation + Snapshot = snapshot, -- Snapshot object with root table + path + } + + -- Fire callbacks for this node + fireNodeCallbacks(callbacks, parentPath, nodePath, nodeKey, node, metadata) +end + +--[=[ + Walks ancestor levels of `basePath` from `fromDepth` down to 0 (inclusive), + firing `emitKeyChanged`/`emitValueChanged` for each level. Shared by + ChangeDetector's own ancestor notifications and TableManager's synthetic + (array-operation / ForceNotify) ancestor notifications, which differ in: + + - Walk start: ChangeDetector skips the captured level itself + (`fromDepth = #basePath - 1`, already processed by DFS); TableManager + includes its base level (`fromDepth = #basePath`). + - Value source: `rootTable` is navigated to produce live current values + (ChangeDetector); `nil` to always pass nil values (TableManager's + synthetic notifications carry no Diff/value payload). + - `keyChangedMode`: + - "parent": at level i >= 1, fires KeyChanged(parentPath, basePath[i], + keyValue, nil, metadata) BEFORE ValueChanged(currentPath, ...). + (ChangeDetector.) + - "child": at level i < #basePath, fires ValueChanged(currentPath, ...) + THEN KeyChanged(currentPath, basePath[i + 1], nil, nil, metadata). + (TableManager.) + - "none": only ValueChanged fires at each level (TableManager's + ForceNotify with includeKeyChanged = false). +]=] +function ChangeDetector.EmitAncestorNotifications( + basePath: PathArray, + fromDepth: number, + metadata: ChangeMetadata, + rootTable: any?, + keyChangedMode: "parent" | "child" | "none", + emitKeyChanged: ( + path: PathArray, + key: any, + newValue: any, + oldValue: any, + metadata: ChangeMetadata + ) -> (), + emitValueChanged: ( + path: PathArray, + newValue: any, + oldValue: any, + metadata: ChangeMetadata + ) -> () +) + -- Pre-navigate rootTable once, caching the value at each depth 0..fromDepth. + -- The original code re-navigated from the root at every ancestor level + -- (O(0+1+…+depth) = O(depth²) hash lookups); this reduces it to one O(depth) pass. + local cachedValues: { any }? + if rootTable ~= nil then + cachedValues = table.create(fromDepth + 1) + local v: any = rootTable + cachedValues[1] = v -- depth 0 → index 1 + for d = 1, fromDepth do + if type(v) == "table" then + v = (v :: any)[basePath[d]] + else + v = nil + end + cachedValues[d + 1] = v -- depth d → index d+1 + end + end + + for i = fromDepth, 0, -1 do + local currentValue: any = if cachedValues then cachedValues[i + 1] else nil + + if keyChangedMode == "parent" and i >= 1 then + -- Build parentPath (i-1 elements) with a manual loop, then derive + -- currentPath via table.clone + one append — avoids copying basePath twice. + -- Each path is a fresh unique table because Signal:Fire leaks the reference. + local parentPath: PathArray = table.create(i - 1) + for j = 1, i - 1 do + parentPath[j] = basePath[j] + end + local currentPath: PathArray = table.clone(parentPath) + currentPath[i] = basePath[i] + + local key = basePath[i] + local keyValue = nil + if currentValue ~= nil and type(currentValue) == "table" then + keyValue = (currentValue :: any)[key] + end + emitKeyChanged(parentPath, key, keyValue, nil, metadata) + emitValueChanged(currentPath, currentValue, currentValue, metadata) + else + local currentPath: PathArray = table.create(i) + for j = 1, i do + currentPath[j] = basePath[j] + end + emitValueChanged(currentPath, currentValue, currentValue, metadata) + + if keyChangedMode == "child" and i < #basePath then + local changedKey = basePath[i + 1] + emitKeyChanged(currentPath, changedKey, nil, nil, metadata) + end + end + end +end + +--[=[ + Notifies parent levels above the captured path once per assignment operation. + Ancestor callbacks receive Diff=nil and share OriginPath/OriginDiff metadata. +]=] +function ChangeDetector:_fireAncestorCallbacks( + capturedPath: PathArray, + rootDiff: Diff.DiffNode, + snapshot: AncestorSnapshot +) + -- If captured at root level (empty path), no ancestors to notify + if #capturedPath == 0 then + return + end + + local callbacks = self._callbacks + + -- Create metadata for ancestor notifications + -- Diff is nil to indicate these are ancestor notifications + -- OriginPath is the captured path (where the assignment operation occurred) + -- Snapshot provides context for ancestor value access + local metadata: ChangeMetadata = { + Diff = nil, + OriginPath = capturedPath, + OriginDiff = rootDiff, + Snapshot = snapshot, + } + + ChangeDetector.EmitAncestorNotifications( + capturedPath, + #capturedPath - 1, + metadata, + snapshot.RootTable, + "parent", + callbacks.OnKeyChanged, + callbacks.OnValueChanged + ) +end + +return ChangeDetector diff --git a/lib/tablemanager2/src/CoalescedFlush.luau b/lib/tablemanager2/src/CoalescedFlush.luau new file mode 100644 index 00000000..891f5cc0 --- /dev/null +++ b/lib/tablemanager2/src/CoalescedFlush.luau @@ -0,0 +1,101 @@ +--!strict +--[=[ + @ignore + @class CoalescedFlush + + Routes a flush request through `Config.FlushMode`. "immediate" (the + default) runs `TableManager:_doFlush` synchronously, same as before this + module existed. "coalesced" defers it to frame-end (`task.defer`) and + merges every path requested before then into one pending flush root — the + running common ancestor of those paths — so a frame with N writes under + one subtree costs one shadow diff/reconcile instead of N, at the cost of + widening to the whole tree when writes are scattered across unrelated + branches. + + Only paths already known trackable (per `Coverage.IsTrackable`) ever reach + `Request`: callers (`Mutator.applyWrite`/`applyRootSet`) are responsible + for that gate, exactly as they are for `_doFlush` itself. +]=] + +const PathHelpers = require("./PathHelpers") +const TMTypes = require("./TMTypes") + +--// Types //-- +type PathArray = PathHelpers.PathArray +type TableManagerLike = TMTypes.TableManagerLike + +const CoalescedFlush = {} + +-- The longest shared prefix of `a` and `b` — their common ancestor path. `{}` +-- (root) when they diverge at the first segment. +local function lowestCommonAncestor(a: PathArray, b: PathArray): PathArray + const result = {} + const minLen = math.min(#a, #b) + for i = 1, minLen do + if a[i] ~= b[i] then + break + end + table.insert(result, a[i]) + end + return result +end + +--[=[ + Routes a flush request for `path` through `self._flushMode`. In + "immediate" mode this is exactly `self:_doFlush(path)`. In "coalesced" + mode it merges `path` into the pending frame-end flush root and (the first + time within a frame) schedules the `task.defer` that drains it. +]=] +function CoalescedFlush.Request(self: TableManagerLike, path: PathArray) + if self._flushMode ~= "coalesced" then + self:_doFlush(path) + return + end + + if self._pendingFlushRoot == nil then + self._pendingFlushRoot = table.clone(path) + else + self._pendingFlushRoot = lowestCommonAncestor(self._pendingFlushRoot, path) + end + + if self._coalescedFlushScheduled then + return + end + self._coalescedFlushScheduled = true + + task.defer(function() + -- Read pending state fresh here (not captured above) so this closure + -- self-corrects if `FlushPending` already drained it earlier in the + -- frame (e.g. via `Suspend`/`Flush`): it then just no-ops below. + self._coalescedFlushScheduled = false + const root = self._pendingFlushRoot + self._pendingFlushRoot = nil + if self._Destroyed or root == nil then + return + end + self:_doFlush(root) + end) +end + +--[=[ + Synchronously runs and clears any pending coalesced flush for `self`. + + Required before anything that takes a fresh baseline directly off live + data while a coalesced flush is still outstanding (`Suspend`'s pre-batch + snapshot, an explicit `Flush`) — otherwise that baseline silently absorbs + the not-yet-reported transition the pending flush was holding, and the + persistent shadow (once the pending flush eventually no-ops against it) + never surfaces it to any listener. Flushing first makes the shadow + authoritative again before the new baseline is taken. +]=] +function CoalescedFlush.FlushPending(self: TableManagerLike) + const root = self._pendingFlushRoot + if root == nil then + return + end + self._pendingFlushRoot = nil + self._coalescedFlushScheduled = false + self:_doFlush(root) +end + +return CoalescedFlush diff --git a/lib/tablemanager2/src/Coverage.luau b/lib/tablemanager2/src/Coverage.luau new file mode 100644 index 00000000..103f636d --- /dev/null +++ b/lib/tablemanager2/src/Coverage.luau @@ -0,0 +1,102 @@ +--!strict +--[=[ + @ignore + @class Coverage + + Shared "could a write at this path be observed by anything at all" gate. + Used by `Mutator` (skip the shadow-seed + flush for unobserved writes), + `Emitter` (skip the array-shadow-sync for unobserved array ops), + `TableManager` (the public `Flush`), and `BatchFlush` (the post-Resume + shadow reconciliation pass). Kept as its own leaf module (rather than + living in `Mutator`, which it originated in) so `Emitter`/`TableManager`/ + `BatchFlush` can all call it without creating a circular require. + + A single OR of three independent observation sources: + - a `ListenerRegistry` listener covering `path` (`CoversChangesAt`); + - a connection on one of the 7 global Signals (each fires for ANY path, + so any connection forces full tracking); + - an active link group (cross-manager fan-out rides inside the same + diff-dispatch this gate would otherwise skip — see + `LinkGroup.FanOutSet`/`FanOutArrayOp`). + + False means a write at `path` is provably unobservable right now. +]=] + +const PathHelpers = require("./PathHelpers") +const TMTypes = require("./TMTypes") +const Diff = require("./Diff") + +--// Types //-- +type PathArray = PathHelpers.PathArray +type TM_Internal = TMTypes.TM_Internal +type TableManagerLike = TMTypes.TableManagerLike + +const Coverage = {} + +--[=[ + True if a change at `path` would fire any of THIS manager's own + listeners/signals — a global Signal connection (each fires for any path) or + a registry listener covering `path` (including via an ancestor/subtree, see + `ListenerRegistry.CoversChangesAt`). Unlike `IsTrackable`, membership in a + link group does NOT make this true: it answers "does this manager itself + observe `path`", independent of fan-out obligations. +]=] +function Coverage.IsLocallyObserved(self: TableManagerLike, path: PathArray): boolean + if + self.ValueChanged:IsConnectedTo() + or self.KeyAdded:IsConnectedTo() + or self.KeyRemoved:IsConnectedTo() + or self.KeyChanged:IsConnectedTo() + or self.ArrayInserted:IsConnectedTo() + or self.ArrayRemoved:IsConnectedTo() + or self.ArraySet:IsConnectedTo() + then + return true + end + return self._listenerRegistry:CoversChangesAt(path) +end + +function Coverage.IsTrackable(self: TableManagerLike, path: PathArray): boolean + -- An active link group forces tracking even with no local observers: a + -- member's own write must still diff so it can fan out to members that DO + -- observe (cross-manager fan-out rides inside this diff-dispatch). + if self._linkGroups ~= nil and self._linkGroups[1] ~= nil then + return true + end + return Coverage.IsLocallyObserved(self, path) +end + +--[=[ + True if some ancestor of `path` (root through, and including, its direct + parent) is marked opaque. `Shadow` can never safely persist an entry + through an opaque ancestor: an opaque value is mirrored BY REFERENCE (so + identity comparisons at ITS OWN level still work), which means treating it + as a navigable container for a deeper path's shadow entry would alias live + data — the next write through that same path would silently mutate the + "old" baseline together with "new", or (if deep-copied instead) would + violate the entire point of marking it opaque (callers may rely on it + never being iterated/cloned, e.g. an Instance or a class instance with a + metatable). + + Callers use this to bypass the persistent shadow for such paths and fall + back to a one-off diff using a value already read fresh from live data — + matching the old per-write CaptureSnapshot behavior for this narrow case. +]=] +function Coverage.PassesThroughOpaqueAncestor(self: TableManagerLike, path: PathArray, ctx: Diff.Ctx?): boolean + if ctx == nil then + return false + end + local current: any = self.Raw + for i = 1, #path - 1 do + if type(current) ~= "table" then + return false + end + if ctx.isOpaque(current) then + return true + end + current = current[path[i]] + end + return type(current) == "table" and ctx.isOpaque(current) +end + +return Coverage diff --git a/lib/tablemanager2/src/Diff.luau b/lib/tablemanager2/src/Diff.luau new file mode 100644 index 00000000..4f178354 --- /dev/null +++ b/lib/tablemanager2/src/Diff.luau @@ -0,0 +1,411 @@ +local PathHelpers = require("./PathHelpers") + +-- ─── Types ─────────────────────────────────────────────────────────────────── + +export type Path = { string | number | boolean } + +export type DiffType = "changed" | "added" | "removed" | "descendantChanged" + +export type DiffEntry = { + path: Path, + type: DiffType, + old: any, + new: any, +} + +export type DiffTree = { [any]: DiffNode } + +-- Sentinel key used inside a DiffTree to carry the scalar side of a +-- table<->scalar transition (e.g. the old scalar when a key becomes a table). +-- A unique userdata is used rather than "" so a genuine empty-string user key +-- cannot collide with it. `flatten`/`ChangeDetector` skip this key when building +-- paths. Exported so ChangeDetector can recognise it. +local SCALAR_SENTINEL = newproxy(false) + +export type DiffNode = { + type: DiffType, + old: any, + new: any, + children: DiffTree?, +} + +export type Snapshot = { + value: any, + ref: any, + children: { [any]: Snapshot }?, + -- Cached so diff_tables/diff can skip re-probing the registry: set by + -- `snapshot()` when this node's ref is opaque (directly marked, or a + -- direct child of an `OpaqueChildren`-marked parent). + opaque: boolean?, +} + +-- Oracle handed in by ChangeDetector (built from OpaqueRegistry); kept as a +-- structural type here (rather than requiring OpaqueRegistry) so Diff stays +-- standalone and every function's behavior is exactly unchanged when the +-- caller passes no ctx at all. +export type Ctx = { + isOpaque: (ref: any) -> boolean, + hasOpaqueChildren: (ref: any) -> boolean, +} + +-- ─── Opacity helpers ───────────────────────────────────────────────────────── + +-- Resolves whether `v` should be treated as an opaque leaf for diffing. +-- Prefers the cached flag on `snap` (already resolved by `snapshot()`, +-- including any parent `hasOpaqueChildren` marking) when a snapshot is +-- available; only consults `ctx` directly when there is no snapshot to read +-- (e.g. the snapshot-less `diff(oldValue, newValue)` call shape), in which +-- case `parentChildrenOpaque` (hoisted once per container by the caller) +-- covers the `hasOpaqueChildren` case. +local function isOpaqueValue(v: any, snap: Snapshot?, ctx: Ctx?, parentChildrenOpaque: boolean): boolean + if snap ~= nil then + return snap.opaque == true + end + if ctx == nil or type(v) ~= "table" then + return false + end + if parentChildrenOpaque then + return true + end + return ctx.isOpaque(v) +end + +-- Resolves the LIVE identity for `v`, given its (possibly absent) snapshot. +-- `snap.ref` is always the live table (snapshot() never clones a ref away — +-- even an ordinary, non-opaque child's `ref` is the live table; only its +-- `.value` is a frozen copy), and is nil only for scalars, where `.value` is +-- already the literal live scalar. Used when building an opaque-leaf +-- comparison so a non-opaque side (its own `t[k]` may be a frozen clone, +-- e.g. `before.value[k]`/`after.value[k]`) still reports the real reference, +-- not the clone, as `old`/`new`. +local function liveValue(v: any, snap: Snapshot?): any + if snap == nil then + return v + end + if snap.ref ~= nil then + return snap.ref + end + return snap.value +end + +-- ─── Snapshot ──────────────────────────────────────────────────────────────── + +local function snapshot(value: any, ctx: Ctx?): Snapshot + if type(value) ~= "table" then + return { value = value, ref = nil, children = nil } + end + + if ctx and ctx.isOpaque(value) then + return { value = value, ref = value, children = nil, opaque = true } + end + + -- Hoisted once per container: a direct table-typed child is forced into + -- the opaque-leaf shape even if it isn't itself individually registered. + local childrenOpaque = ctx ~= nil and ctx.hasOpaqueChildren(value) + + local children = {} + local copy = {} + for k, v in value do + local childSnap: Snapshot + if childrenOpaque and type(v) == "table" then + childSnap = { value = v, ref = v, children = nil, opaque = true } + else + childSnap = snapshot(v, ctx) + end + children[k] = childSnap + copy[k] = childSnap.value + end + + return { value = table.freeze(copy), ref = value, children = children } +end + +-- ─── Internal helpers ──────────────────────────────────────────────────────── + +-- Callers only ever invoke this with a `value` already known to be non-opaque +-- (opacity is checked one level up, where both sides of the comparison are in +-- hand) — but `value`'s OWN children may still be opaque, hence the +-- per-child check below. +local function make_removal_tree(value: any, ctx: Ctx?): DiffNode + if type(value) ~= "table" then + return { type = "removed", old = value, new = nil, children = nil } + end + + local childrenOpaque = ctx ~= nil and ctx.hasOpaqueChildren(value) + local children: DiffTree = {} + for k, v in pairs(value) do + if type(v) == "table" and (childrenOpaque or (ctx ~= nil and ctx.isOpaque(v))) then + children[k] = { type = "removed", old = v, new = nil, children = nil } + else + children[k] = make_removal_tree(v, ctx) + end + end + + return { type = "removed", old = value, new = nil, children = children } +end + +-- See `make_removal_tree` re: the non-opacity invariant on `value` itself. +local function make_addition_tree(value: any, ctx: Ctx?): DiffNode + if type(value) ~= "table" then + return { type = "added", old = nil, new = value, children = nil } + end + + local childrenOpaque = ctx ~= nil and ctx.hasOpaqueChildren(value) + local children: DiffTree = {} + for k, v in pairs(value) do + if type(v) == "table" and (childrenOpaque or (ctx ~= nil and ctx.isOpaque(v))) then + children[k] = { type = "added", old = nil, new = v, children = nil } + else + children[k] = make_addition_tree(v, ctx) + end + end + + return { type = "added", old = nil, new = value, children = children } +end + +local function make_descendant_node(old: any, new: any, children: DiffTree): DiffNode + return { type = "descendantChanged", old = old, new = new, children = children } +end + +-- Builds the leaf node for an opaque slot (no children walk): nil/nil -> no +-- change (returns nil), nil-old -> added, nil-new -> removed, else changed-by-ref. +local function make_opaque_leaf(v1: any, v2: any): DiffNode? + if v1 == nil and v2 == nil then + return nil + elseif v1 == nil then + return { type = "added", old = nil, new = v2, children = nil } + elseif v2 == nil then + return { type = "removed", old = v1, new = nil, children = nil } + elseif v1 ~= v2 then + return { type = "changed", old = v1, new = v2, children = nil } + end + return nil +end + +-- ─── Core diff ─────────────────────────────────────────────────────────────── + +local function diff_tables( + t1: { [any]: any }, + t2: { [any]: any }, + snap1: Snapshot?, + snap2: Snapshot?, + ctx: Ctx? +): DiffTree? + local tree: DiffTree = {} + local visited = {} + + -- Hoisted once per container (not per key): whether t1/t2's DIRECT + -- children should be treated as opaque leaves regardless of individual + -- registration. Only meaningful in the ctx-fallback (no-snapshot) path — + -- when a snapshot is present, this was already baked into each child's + -- cached `opaque` flag by `snapshot()`. + local parentChildrenOpaque = ctx ~= nil and (ctx.hasOpaqueChildren(t1) or ctx.hasOpaqueChildren(t2)) + + for k, v1 in pairs(t1) do + visited[k] = true + local v2 = t2[k] + + local child_snap1 = if snap1 and snap1.children then snap1.children[k] else nil + local child_snap2 = if snap2 and snap2.children then snap2.children[k] else nil + + if + isOpaqueValue(v1, child_snap1, ctx, parentChildrenOpaque) + or isOpaqueValue(v2, child_snap2, ctx, parentChildrenOpaque) + then + local leaf = make_opaque_leaf(liveValue(v1, child_snap1), liveValue(v2, child_snap2)) + if leaf then + tree[k] = leaf + end + continue + end + + local ref1 = if child_snap1 then child_snap1.ref else nil + local ref2 = if child_snap2 then child_snap2.ref else nil + + local is_v1_table = type(v1) == "table" + local is_v2_table = type(v2) == "table" + + if v2 == nil then + tree[k] = make_removal_tree(v1, ctx) + elseif is_v1_table and is_v2_table then + local ref_changed = ref1 ~= nil and ref2 ~= nil and ref1 ~= ref2 + local child_tree = diff_tables(v1, v2, child_snap1, child_snap2, ctx) + + if ref_changed and child_tree == nil then + tree[k] = { type = "changed", old = v1, new = v2, children = nil } + elseif child_tree ~= nil then + tree[k] = make_descendant_node(v1, v2, child_tree) + end + elseif is_v1_table and not is_v2_table then + local children: DiffTree = {} + local removal = make_removal_tree(v1, ctx) + if removal.children then + for ck, cv in pairs(removal.children) do + children[ck] = cv + end + end + children[SCALAR_SENTINEL] = { type = "added", old = nil, new = v2, children = nil } + tree[k] = make_descendant_node(v1, v2, children) + elseif not is_v1_table and is_v2_table then + -- scalar/nil -> table. Carry the whole new table on the node (`new`) so + -- the value is authoritative regardless of child traversal order, and + -- attach the new table's keys as children for granular listeners. No + -- scalar sentinel is needed here (the node itself records old/new). + local children: DiffTree = {} + local addition = make_addition_tree(v2, ctx) + if addition.children then + for ck, cv in pairs(addition.children) do + children[ck] = cv + end + end + local has_children = next(children) ~= nil + tree[k] = { + type = if v1 == nil then "added" else "changed", + old = v1, + new = v2, + children = if has_children then children else nil, + } + elseif v1 ~= v2 then + tree[k] = { type = "changed", old = v1, new = v2, children = nil } + end + end + + for k, v2 in pairs(t2) do + if visited[k] then + continue + end + + local child_snap2 = if snap2 and snap2.children then snap2.children[k] else nil + if isOpaqueValue(v2, child_snap2, ctx, parentChildrenOpaque) then + tree[k] = { type = "added", old = nil, new = liveValue(v2, child_snap2), children = nil } + else + tree[k] = make_addition_tree(v2, ctx) + end + end + + return if next(tree) ~= nil then tree else nil +end + +local function diff(v1: any, v2: any, snap1: Snapshot?, snap2: Snapshot?, ctx: Ctx?): DiffNode? + -- Opacity overrides the table/scalar branching entirely: an opaque value + -- (or nil) on either side is compared like a scalar, regardless of what + -- the other side is. + if isOpaqueValue(v1, snap1, ctx, false) or isOpaqueValue(v2, snap2, ctx, false) then + return make_opaque_leaf(liveValue(v1, snap1), liveValue(v2, snap2)) + end + + local is_v1_table = type(v1) == "table" + local is_v2_table = type(v2) == "table" + + if is_v1_table and is_v2_table then + local children = diff_tables(v1, v2, snap1, snap2, ctx) + if children then + -- Wrap in a root node with descendantChanged type + return make_descendant_node(v1, v2, children) + end + return nil -- No changes + elseif is_v1_table and not is_v2_table then + if v2 == nil then + -- The whole table was removed (table -> nil). Emit a "removed" root so + -- consumers receive a removal event at THIS path, not only at the leaf + -- descendants. Otherwise a consumer that applies leaf removals would be + -- left with an empty table here instead of removing it entirely. + return make_removal_tree(v1, ctx) + end + local tree: DiffTree = {} + local removal = make_removal_tree(v1, ctx) + if removal.children then + for k, v in pairs(removal.children) do + tree[k] = v + end + end + tree[SCALAR_SENTINEL] = { type = "added", old = nil, new = v2, children = nil } + return make_descendant_node(v1, v2, tree) + elseif not is_v1_table and is_v2_table then + local children: DiffTree = {} + local addition = make_addition_tree(v2, ctx) + if addition.children then + for k, v in pairs(addition.children) do + children[k] = v + end + end + local has_children = next(children) ~= nil + + if v1 == nil then + -- nil -> table: a pure addition. Keep the existing descendantChanged + -- shape for non-empty tables (their leaf adds drive consumers). But an + -- EMPTY table has no leaves, and a childless descendantChanged node fires + -- no signal, so the key would never reach a consumer — emit an "added" + -- leaf node instead (defect: empty-table assignment was silent). + if has_children then + return make_descendant_node(v1, v2, children) + end + return { type = "added", old = nil, new = v2, children = nil } + end + + -- scalar -> table: a direct change. Carry the whole new table on the node + -- (`new`) so the value is order-independent, with the table's keys as + -- children for granular listeners. No "" sentinel is used, so a genuine "" + -- key is not folded into the parent path. + return { type = "changed", old = v1, new = v2, children = if has_children then children else nil } + else + -- Scalar to scalar comparison + if v1 == nil and v2 ~= nil then + return { type = "added", old = nil, new = v2, children = nil } + elseif v1 ~= nil and v2 == nil then + return { type = "removed", old = v1, new = nil, children = nil } + elseif v1 ~= v2 then + return { type = "changed", old = v1, new = v2, children = nil } + end + end + + return nil -- No changes +end + +-- ─── Flatten ───────────────────────────────────────────────────────────────── + +local function flatten_node(node: DiffNode, path: Path, result: { DiffEntry }) + -- Add this node if it's not descendantChanged + if node.type ~= "descendantChanged" then + table.insert(result, { + path = path, + type = node.type, + old = node.old, + new = node.new, + }) + end + + -- Recurse into children + if node.children then + for k, child in pairs(node.children) do + local child_path: Path = if k ~= SCALAR_SENTINEL then PathHelpers.Append(path, k) else table.clone(path) + flatten_node(child, child_path, result) + end + end +end + +local function flatten(root: DiffNode?, path: Path?): { DiffEntry } + local finalPath: Path = path or {} + local result: { DiffEntry } = {} + + if root then + flatten_node(root, finalPath, result) + end + + return result +end + +-- ─── Public API ────────────────────────────────────────────────────────────── + +-------------------------------------------------------------------------------- +--// Final Return //-- +-------------------------------------------------------------------------------- + +local Module = {} + +Module.diff = diff +Module.flatten = flatten +Module.snapshot = snapshot +-- The sentinel DiffTree key for the scalar side of a table<->scalar transition. +Module.ScalarSentinel = SCALAR_SENTINEL + +return Module diff --git a/lib/tablemanager2/src/Docs/CLAUDE_ASSESSMENT.md b/lib/tablemanager2/src/Docs/CLAUDE_ASSESSMENT.md new file mode 100644 index 00000000..1dc3521a --- /dev/null +++ b/lib/tablemanager2/src/Docs/CLAUDE_ASSESSMENT.md @@ -0,0 +1,19 @@ +The four production fixes +1. Array ops now carry explicit shift semantics — fixes the hole test (343) and makes everything else possible. A flattened removed entry at a numeric leaf was fundamentally ambiguous: the consumer couldn't tell a shifting ArrayRemove from an in-place nil write, so it guessed with an array-like heuristic and guessed wrong on holes. fireArrayOperation now tags every array delivery with metadata.ArrayOp = { Kind, Index }, and fireAncestorValueChangedNotifications propagates it to the root delivery the diff feed consumes. The ChangeMetadata type in ChangeDetector.luau documents the field. + +2. Batch flush no longer double-delivers arrays — fixes all 7 diff-feed batch tests (568, 603, 617, 660, 687, 705, 736). The root OnChange delivery's OriginDiff contained the array changes (as non-shift-faithful positional entries) and the array flush delivered them again per-op. A new maskTrackedArraysForBranchDiff helper substitutes each still-existing tracked array's pre-batch snapshot value into the branch diff input, so the branch diff reports nothing at array subtrees and the ArrayOp-tagged array flush is the sole owner of those changes. Wholesale array removals still flow through the branch diff (the array flush skips non-table paths), and created-in-batch arrays were already pruned. + +3. Batched root-level writes now reach root listeners — fixes the Swap tests (496, 511, 523) and was also corrupting test 705. The old __root__ flush ran a full-root CheckForChanges captured at path {}, which fires no ancestor delivery — a root OnChange listener literally never saw batched root-level writes, and its diff re-included array changes. Resume now expands __root__ into per-key CheckForChangesBetween({key}) calls (union of pre-batch and current root keys), each masked, each producing a proper root delivery. Swap's two root-level Sets inside its internal batch now arrive as ordinary scalar↔table diffs. + +4. Re-entrant writes are queued — fixes the clamp test (846) at the source, for both feeds. A listener writing back during notification ran its entire nested dispatch (including the root diff delivery) inside the outer one, inverting delivery order. ChangeDetector:_dispatch now queues any check triggered from inside a callback and drains it after the current dispatch completes, so consumers see 100→-50 then -50→0 and end on the clamped value. + +The harness (consumer contract) +ReplicationHarness._connectDiffFeed now implements the contract the TableReplicator rebuild should bake in: ArrayOp deliveries apply as shifting array ops; everything else flattens to plain non-shifting Sets, with entries shadowed by an ancestor entry in the same delivery skipped (the ancestor's new carries the whole subtree — this is what fixes scalar↔table Swaps, where child removals raced the authoritative parent write). The old isArrayLike guessing and descending-removal sort are gone. The contract is documented in both the harness and the findings doc. + +Per-step convergence (966) falls out of the above — I traced all 7 steps on both feeds, including the batch step and the hole-free swap-remove step. + +Caveats worth knowing +Root OnChange listeners now receive one delivery per changed root key for batched root-level writes instead of zero — that's the point, but it's a behavioral change. +A re-entrant write's own listeners now fire after the outer dispatch completes rather than interleaved inside it; a queued check diffs against drain-time state, so chained re-entrant writes can collapse into fewer (still convergent) events. +I traced the batch-lifecycle, listeners-methods, value-listener, and integration-scenarios assertions against the new flush and they all hold, but they're the regression watch for the real run. +Per the repo rules I haven't run anything — please run the suite in Roblox. Expected: all 13 of these failures green, plus the 4 from the previous round, with no regressions in the batch/listener suites. If anything still diverges, ReplicationHarness.DEBUG = true is already on, so the failing test will dump the op log and both Raw tables — send me that output. \ No newline at end of file diff --git a/lib/tablemanager2/src/Docs/EXAMPLES.md b/lib/tablemanager2/src/Docs/EXAMPLES.md new file mode 100644 index 00000000..4e5793bb --- /dev/null +++ b/lib/tablemanager2/src/Docs/EXAMPLES.md @@ -0,0 +1,728 @@ +# TableManager Examples Guide + +Comprehensive examples for using the TableManager system. + +--- + +## Table of Contents + +1. [Basic Setup](#basic-setup) +2. [Value Changes](#value-changes) +3. [Array Operations](#array-operations) +4. [Key Tracking](#key-tracking) +5. [Parent/Child Relationships](#parentchild-relationships) +6. [Global Signals](#global-signals) +7. [Path-Based Access](#path-based-access) +8. [Common Patterns](#common-patterns) +9. [Best Practices](#best-practices) + +--- + +## Basic Setup + +### Creating a TableManager + +```lua +local TableManager = require(path.to.TableManager) + +-- Simple creation +local manager = TableManager.new({ + Player = { + Name = "Alice", + Level = 1 + } +}) + +-- Type-safe creation +type GameData = { + Player: { + Name: string, + Level: number, + Gold: number + } +} + +local gameManager: TableManager = TableManager.new({ + Player = { + Name = "Alice", + Level = 1, + Gold = 100 + } +}) +``` + +### Accessing Data + +```lua +-- Direct access through proxy +print(manager.Proxy.Player.Name) -- "Alice" + +-- Modification (triggers listeners) +manager.Proxy.Player.Level = 5 + +-- Iteration (use generic for, NOT pairs!) +for key, value in manager.Proxy.Player do + print(key, "=", value) +end +``` + +--- + +## Value Changes + +### Basic Value Change Listener + +```lua +local manager = TableManager.new({ + Player = { Health = 100 } +}) + +manager:OnValueChange({"Player", "Health"}, function(newValue, oldValue, metadata) + print("Health:", oldValue, "→", newValue) + print("Changed path:", table.concat(metadata.OriginPath, ".")) + print("Direct change:", metadata.Diff ~= nil) +end) + +manager.Proxy.Player.Health = 80 +-- Output: "Health: 100 → 80" +-- "Changed path: Player.Health" +-- "Direct change: true" +``` + +### Observing Nested Changes + +```lua +local manager = TableManager.new({ + Game = { + World = { + Region = { Zone = 1, Temperature = 20 } + } + } +}) + +-- Listen to parent path +manager:OnValueChange({"Game", "World"}, function(newValue, oldValue, metadata) + print("Origin:", table.concat(metadata.OriginPath, ".")) + if metadata.Diff then + print("Direct replacement at this path") + else + print("Descendant changed under this path") + end +end) + +-- Change deep nested value +manager.Proxy.Game.World.Region.Zone = 2 +-- Output: "Origin: Game.World.Region.Zone" +-- "Descendant changed under this path" +``` + +### Root Level Listener + +```lua +-- Listen to ALL changes in the entire structure +manager:OnValueChange({}, function(newValue, oldValue, metadata) + print("Something changed at:", table.concat(metadata.OriginPath, ".")) +end) +``` + +### OnValueChange vs OnChange + +`OnValueChange` and `OnChange` are for the two most common listening patterns: + +- **`OnValueChange`** fires ONLY when this exact path is directly reassigned. +- **`OnChange`** fires when this path is directly reassigned OR any descendant + of it changes. + +```lua +local manager = TableManager.new({ + Player = { Health = 100, Mana = 50 } +}) + +manager:OnValueChange({"Player"}, function(newValue, oldValue, metadata) + print("Player table was directly replaced") +end) + +manager:OnChange({"Player"}, function(newValue, oldValue, metadata) + print("Player table or one of its fields changed") +end) + +manager.Proxy.Player.Health = 80 +-- Output: "Player table or one of its fields changed" +-- (OnValueChange does NOT fire - only Health changed, not Player itself) + +manager.Proxy.Player = { Health = 100, Mana = 50 } +-- Output: "Player table was directly replaced" +-- "Player table or one of its fields changed" +``` + +### Observe + +`Observe` immediately invokes the callback with the current value (with +`oldValue` and `metadata` both `nil`), then behaves like `OnValueChange` for +subsequent changes. Useful for binding UI to data without separately calling +`Get` first. + +```lua +local manager = TableManager.new({ + Player = { Health = 100 } +}) + +manager:Observe({"Player", "Health"}, function(newValue, oldValue, metadata) + if metadata == nil then + print("Initial value:", newValue) + else + print("Health changed:", oldValue, "→", newValue) + end +end) +-- Output immediately: "Initial value: 100" + +manager.Proxy.Player.Health = 80 +-- Output: "Health changed: 100 → 80" +``` + +### Wildcard Listeners + +A `"*"` path segment matches any literal key at that position, which is +useful for dynamic collections (e.g. per-player data) without needing to +register a listener for each existing key or re-register on `KeyAdded`. +The matched keys are available via `metadata.WildcardMatches`, in +left-to-right order. + +```lua +local manager = TableManager.new({ + Players = { + p123 = { Health = 100 }, + p456 = { Health = 80 }, + } +}) + +manager:OnValueChange({"Players", "*", "Health"}, function(newValue, oldValue, metadata) + local playerId = metadata.WildcardMatches[1] + print(playerId, "health:", oldValue, "→", newValue) +end) + +manager.Proxy.Players.p123.Health = 90 -- "p123 health: 100 → 90" +manager.Proxy.Players.p456.Health = 70 -- "p456 health: 80 → 70" + +-- A new player added later is also covered by a wildcard ancestor listener: +manager:OnChange({"Players", "*"}, function(_, _, metadata) + if metadata.WildcardMatches then + print("Player data changed for:", metadata.WildcardMatches[1]) + end +end) + +manager.Proxy.Players.p789 = { Health = 100 } +-- Output: "Player data changed for: p789" +``` + +> Note: a listener registered with `Once = true` on a wildcard path fires once +> **total** across all matching keys, not once per key, since it lives on a +> single tree node. + +--- + +## Array Operations + +### Array Insertion + +```lua +local manager = TableManager.new({ + Inventory = { "Sword", "Shield" } +}) + +-- Listen for insertions +manager:OnArrayInsert({"Inventory"}, function(index, newValue, metadata) + print("Inserted", newValue, "at index", index) +end) + +-- Insert at end +manager:Insert({"Inventory"}, "Potion") +-- Result: {"Sword", "Shield", "Potion"} + +-- Insert at specific position +manager:Insert({"Inventory"}, 1, "Bow") +-- Result: {"Bow", "Sword", "Shield", "Potion"} +``` + +### Array Removal + +```lua +local manager = TableManager.new({ + Queue = { "Task1", "Task2", "Task3" } +}) + +-- Listen for removals +manager:OnArrayRemove({"Queue"}, function(index, oldValue, metadata) + print("Removed", oldValue, "from index", index) +end) + +-- Remove last element +local removed = manager:Remove({"Queue"}, #manager.Proxy.Queue) +print("Removed:", removed) -- "Task3" + +-- Remove specific index +removed = manager:Remove({"Queue"}, 1) +print("Removed:", removed) -- "Task1" +``` + +### Array Modification + +```lua +local manager = TableManager.new({ + Items = { "Bronze Sword", "Iron Shield" } +}) + +-- Listen for element modifications +manager:OnArraySet({"Items"}, function(index, newValue, oldValue, metadata) + print("Item", index, "upgraded:", oldValue, "→", newValue) +end) + +-- Modify existing element (NOT insertion or removal) +manager.Proxy.Items[1] = "Steel Sword" +-- Output: "Item 1 upgraded: Bronze Sword → Steel Sword" +``` + +--- + +## Key Tracking + +### Key Addition + +```lua +local manager = TableManager.new({ + Settings = { Volume = 50 } +}) + +manager:OnKeyAdd({"Settings"}, function(newValue, metadata) + local key = if metadata.Diff then metadata.Diff.key else "" + print("New setting:", key, "=", newValue) +end) + +-- Add new key +manager.Proxy.Settings.Brightness = 80 +-- Output: "New setting: Brightness = 80" + +-- Modifying existing key does NOT trigger OnKeyAdd +manager.Proxy.Settings.Volume = 75 -- No output (triggers OnKeyChange instead) +``` + +### Key Removal + +```lua +local manager = TableManager.new({ + Player = { + Name = "Alice", + TempBoost = 10, + TempBuff = "Speed" + } +}) + +manager:OnKeyRemove({"Player"}, function(oldValue, metadata) + local key = if metadata.Diff then metadata.Diff.key else "" + print("Removed:", key, "(was", oldValue .. ")") +end) + +-- Remove keys by setting to nil +manager.Proxy.Player.TempBoost = nil +-- Output: "Removed: TempBoost (was 10)" + +manager.Proxy.Player.TempBuff = nil +-- Output: "Removed: TempBuff (was Speed)" +``` + +### Key Change (Modification) + +```lua +local manager = TableManager.new({ + Config = { Timeout = 30, Retries = 3 } +}) + +manager:OnKeyChange({"Config"}, function(key, newValue, oldValue, metadata) + print(key, "modified:", oldValue, "→", newValue) +end) + +-- Modify existing key (triggers OnKeyChange) +manager.Proxy.Config.Timeout = 60 +-- Output: "Timeout modified: 30 → 60" + +-- Add new key (does NOT trigger OnKeyChange, triggers OnKeyAdd) +manager.Proxy.Config.MaxConnections = 100 -- No output here + +-- Remove key (does NOT trigger OnKeyChange, triggers OnKeyRemove) +manager.Proxy.Config.Retries = nil -- No output here +``` + +--- + +## Parent/Child Relationships + +### Understanding Diff and OriginPath + +```lua +local manager = TableManager.new({ + Game = { + World = { + Region = { Zone = 1 } + } + } +}) + +manager:OnValueChange({"Game", "World"}, function(newValue, oldValue, metadata) + local path = table.concat(metadata.OriginPath, ".") + + if metadata.Diff then + print("Direct change at:", path) + else + print("Descendant change originated at:", path) + end +end) + +-- Scenario 1: Descendant change under Game.World +manager.Proxy.Game.World.Region.Zone = 2 +-- Output: "Descendant change originated at: Game.World.Region.Zone" + +-- Scenario 2: Direct replacement at Game.World +manager.Proxy.Game.World = { Region = { Zone = 3 } } +-- Output: "Direct change at: Game.World" + +-- Note: listeners registered at {"Game", "World"} only fire for that path and +-- descendant-origin changes, not unrelated parent-only replacements. +``` + +### Cascading Listeners + +```lua +local manager = TableManager.new({ + App = { + UI = { + Menu = { Visible = true } + } + } +}) + +-- Listener 1: Root level +manager:OnValueChange({}, function(newValue, oldValue, metadata) + print("[ROOT]", table.concat(metadata.OriginPath, ".")) +end) + +-- Listener 2: App level +manager:OnValueChange({"App"}, function(newValue, oldValue, metadata) + print("[APP]", table.concat(metadata.OriginPath, ".")) +end) + +-- Listener 3: UI level +manager:OnValueChange({"App", "UI"}, function(newValue, oldValue, metadata) + print("[UI]", table.concat(metadata.OriginPath, ".")) +end) + +-- One change triggers all three listeners! +manager.Proxy.App.UI.Menu.Visible = false +-- Output: +-- [ROOT] App.UI.Menu.Visible +-- [APP] App.UI.Menu.Visible +-- [UI] App.UI.Menu.Visible +``` + +--- + +## Global Signals + +### ValueChanged Signal + +```lua +local manager = TableManager.new({ + Player = { Name = "Alice" }, + Settings = { Volume = 75 } +}) + +-- Listen to ALL value changes globally +manager.ValueChanged:Connect(function(path, newValue, oldValue) + print("Global change at:", table.concat(path, ".")) + print("Value:", oldValue, "→", newValue) +end) + +manager.Proxy.Player.Name = "Bob" +manager.Proxy.Settings.Volume = 100 +-- Both trigger the global listener +``` + +### KeyAdded Signal + +```lua +-- Listen to ALL key additions globally +manager.KeyAdded:Connect(function(path, key, value) + print("New key added:", key) + print("At path:", table.concat(path, ".")) + print("Value:", value) +end) + +manager.Proxy.Player.Level = 1 +-- Output: "New key added: Level" +-- "At path: Player" +-- "Value: 1" +``` + +### ArrayInserted Signal + +```lua +local manager = TableManager.new({ + Players = {}, + Items = {} +}) + +-- Listen to ALL array insertions globally +manager.ArrayInserted:Connect(function(path, index, value) + print("Array insert at:", table.concat(path, ".")) + print("Index:", index, "Value:", value) +end) + +manager:Insert({"Players"}, "Alice") +manager:Insert({"Items"}, "Sword") +-- Both trigger the global listener +``` + +--- + +## Path-Based Access + +### Using Get Method + +```lua +local manager = TableManager.new({ + Player = { + Name = "Alice", + Stats = { + Health = 100, + Mana = 50 + } + } +}) + +-- Get values by path +local name = manager:Get({"Player", "Name"}) -- "Alice" +local health = manager:Get({"Player", "Stats", "Health"}) -- 100 + +-- Get returns nil for non-existent paths +local missing = manager:Get({"NonExistent", "Path"}) -- nil + +-- Get root (returns managed raw table) +local root = manager:Get({}) -- Same as manager.Raw +``` + +### Using Set Method + +```lua +-- Set values by path +manager:Set({"Player", "Name"}, "Bob") +manager:Set({"Player", "Stats", "Health"}, 80) + +-- Equivalent to: +-- manager.Proxy.Player.Name = "Bob" +-- manager.Proxy.Player.Stats.Health = 80 + +-- Set triggers all normal events +manager:OnValueChange({"Player", "Name"}, function(newValue, oldValue) + print("Name changed via Set:", oldValue, "→", newValue) +end) + +manager:Set({"Player", "Name"}, "Charlie") +-- Triggers the listener +``` + +### Dynamic Path Building + +```lua +-- Build paths dynamically +local function watchStat(statName) + local path = {"Player", "Stats", statName} + + manager:OnValueChange(path, function(newValue, oldValue) + print(statName, "changed:", oldValue, "→", newValue) + end) +end + +watchStat("Health") +watchStat("Mana") + +-- Both are now watched +manager.Proxy.Player.Stats.Health = 75 +manager.Proxy.Player.Stats.Mana = 40 +``` + +--- + +> Note: Fusion helper APIs are not part of this package's current public API. +> Keep integration logic in application code using signals/listeners from this module. + +--- + +## Common Patterns + +### Undo/Redo System + +```lua +local history = {} +local historyIndex = 0 + +manager.ValueChanged:Connect(function(path, newValue, oldValue) + -- Record change for undo + historyIndex += 1 + history[historyIndex] = { + path = path, + oldValue = oldValue, + newValue = newValue + } + -- Clear redo history + for i = historyIndex + 1, #history do + history[i] = nil + end +end) + +function undo() + if historyIndex > 0 then + local change = history[historyIndex] + manager:Set(change.path, change.oldValue) + historyIndex -= 1 + end +end + +function redo() + if historyIndex < #history then + historyIndex += 1 + local change = history[historyIndex] + manager:Set(change.path, change.newValue) + end +end +``` + +### Validation System + +```lua +local manager = TableManager.new({ + Player = { Level = 1 } +}) + +manager:OnValueChange({"Player", "Level"}, function(newValue, oldValue, metadata) + -- Validate level is within bounds + if newValue < 1 or newValue > 100 then + warn("Invalid level:", newValue, "- reverting to", oldValue) + manager.Proxy.Player.Level = oldValue + end +end) + +manager.Proxy.Player.Level = 150 -- Automatically reverted to previous value +``` + +### Auto-Save System + +```lua +local saveDebounce = {} + +manager.ValueChanged:Connect(function(path, newValue, oldValue) + local pathStr = table.concat(path, ".") + + -- Cancel existing debounce for this path + if saveDebounce[pathStr] then + task.cancel(saveDebounce[pathStr]) + end + + -- Schedule save after 2 seconds of no changes + saveDebounce[pathStr] = task.delay(2, function() + print("Auto-saving:", pathStr) + -- Save to DataStore here + saveDebounce[pathStr] = nil + end) +end) +``` + +### Derived Values + +```lua +local manager = TableManager.new({ + Player = { + Stats = { + Strength = 10, + Agility = 8 + } + } +}) + +-- Auto-calculate total stats +local totalStats = 0 + +local function updateTotal() + local strength = manager:Get({"Player", "Stats", "Strength"}) + local agility = manager:Get({"Player", "Stats", "Agility"}) + totalStats = strength + agility + print("Total stats:", totalStats) +end + +manager:OnValueChange({"Player", "Stats"}, function() + updateTotal() +end) + +updateTotal() -- Initial calculation +``` + +--- + +## Best Practices + +### ✅ DO + +```lua +-- Use generic for iteration +for key, value in manager.Proxy.config do + print(key, value) +end + +-- Use TableManager methods for arrays +manager:Insert({"items"}, "newItem") +manager:Remove({"items"}, 1) + +-- Use Set/Get for dynamic paths +manager:Set({"Player", "Stats", statName}, value) + +-- Store connections for cleanup +local conn = manager:OnValueChange({}, callback) +-- Later: conn:Disconnect() + +-- Use type annotations +type MyData = { ... } +local manager: TableManager = TableManager.new({...}) +``` + +### ❌ DON'T + +```lua +-- Don't use pairs() or ipairs() on proxies +for k, v in pairs(manager.Proxy) do end -- ❌ Won't work! + +-- Don't use table.* functions on proxies +table.insert(manager.Proxy.items, "value") -- ❌ Won't work! +table.remove(manager.Proxy.items) -- ❌ Won't work! + +-- Don't compare proxy == original directly +if manager.Proxy == originalTable then end -- ❌ Won't work! +-- Use: manager._proxyManager:Equals(manager.Proxy, originalTable) + +-- Don't set root directly +manager:Set({}, newTable) -- ❌ Errors! +``` + +--- + +## Summary + +TableManager provides a powerful, type-safe way to observe and manage nested table data in Roblox. Key takeaways: + +- 🎯 **Automatic change detection** at any depth +- 🔄 **Parent/child relationships** for cascading updates +- 📡 **Global signals** for cross-cutting concerns +- ✅ **Type-safe** with full autocomplete support +- ⚡ **Performance optimized** with proxy caching + +For more examples, see: +- `Tests/TableManagerDemo.server.luau` - Interactive demonstration +- `Tests/TableManager.spec.luau` - Comprehensive test cases +- `PROXY_USERDATA_NOTES.md` - Technical details about proxy behavior diff --git a/lib/tablemanager2/src/Docs/LINKED-FANOUT-OPTIMIZATION.md b/lib/tablemanager2/src/Docs/LINKED-FANOUT-OPTIMIZATION.md new file mode 100644 index 00000000..9bf93d8c --- /dev/null +++ b/lib/tablemanager2/src/Docs/LINKED-FANOUT-OPTIMIZATION.md @@ -0,0 +1,135 @@ +# Linked Fan-Out Optimization + +How a write on one member of a `LinkGroup` is replayed to the other members, +**before** and **after** the fan-out optimization (2026-06-20). + +## Goal + +A write to a region shared by N linked managers should cost **one diff, +regardless of N** — the origin already diffed it; recipients should not +re-diff what was already diffed. Dispatch (firing each observing member's own +listeners) is inherently per-observer and cannot be reduced; the per-member +shadow copy is addressed only by the deferred Phase C below. + +## Current behavior (before optimization) + +Every recipient independently re-runs `Diff.diff` and re-copies the changed +subtree into its own shadow — even recipients that observe nothing at that path. + +```mermaid +flowchart TD + S["origin: Set(path, value)"] --> W["write lands in Raw"] + W --> OF["origin _doFlush()"] + OF --> OD["Diff.diff ONCE — fire origin listeners — reconcile origin shadow"] + OD --> FO["fan out each changed leaf to the group"] + subgraph EACH["for EACH other linked member (×N)"] + direction TB + NA["_NotifyApplied(op)"] --> P["pruneDetachedValue"] + P --> RD["CheckForChangesBetween → Diff.diff AGAIN — re-diff"] + RD --> FL["fire recipient's listeners"] + FL --> MZ["Materialize → copyForShadow — per-member copy"] + end + FO --> NA + MZ --> DONE["done"] + classDef cost fill:#ffd7d7,stroke:#cc0000,color:#000; + class RD,MZ cost; +``` + +## New behavior (Phase A + B, implemented) + +- **Phase A** — a recipient that observes nothing locally at `op.Path` + (`Coverage.IsLocallyObserved`) skips the diff + dispatch entirely; no listener + or signal of its own would fire anyway. Its shadow is still `Materialize`d so a + write **it** later originates fans out a correct `oldValue`. +- **Phase B** — an observing recipient replays the origin's already-built diff + node (`ChangeDetector:DispatchDiffNode`) instead of re-diffing, when neither + side has per-manager opacity marks (see "Opacity gate"). Otherwise it falls + back to today's `CheckForChangesBetween`. + +```mermaid +flowchart TD + S["origin: Set(path, value)"] --> W["write lands in Raw"] + W --> OF["origin _doFlush()"] + OF --> OD["Diff.diff ONCE — fire origin listeners — reconcile origin shadow"] + OD --> FO["fan out each changed leaf + the origin's diff node"] + subgraph EACH["for EACH other linked member (×N)"] + direction TB + NA["_NotifyApplied(op)"] --> P["pruneDetachedValue"] + P --> OB{"IsLocallyObserved(op.path)?"} + OB -->|"no — observes nothing here"| SK["skip diff + dispatch — Phase A"] + OB -->|"yes"| GB{"op.Diff present AND neither side has per-manager opacity?"} + GB -->|"yes"| RU["DispatchDiffNode(op.Diff) — reuse, NO re-diff — Phase B"] + GB -->|"no"| FB["CheckForChangesBetween — re-diff (fallback)"] + RU --> FL["fire recipient's listeners"] + FB --> FL + FL --> MZ["Materialize → copyForShadow"] + SK --> MZ + end + FO --> NA + MZ --> DONE["done"] + classDef good fill:#d7ffd7,stroke:#00aa00,color:#000; + classDef warn fill:#fff2cc,stroke:#d6a000,color:#000; + class SK,RU good; + class MZ warn; +``` + +## Cost per write to a region shared by N members + +| | `Diff.diff` calls | Dispatch (fire listeners) | Shadow copies | +|---|---|---|---| +| **Current** | 1 + N (re-diff each recipient) | N (incl. members observing nothing) | N | +| **New (A + B)** | **1** (origin only) | observers only | N | +| **+ Phase C** *(deferred)* | 1 | observers | **1** (group-owned shadow) | + +The yellow `Materialize` node is the only remaining per-member cost; collapsing +it to one is what **Phase C** would do. + +## Opacity gate (Phase B) + +Reuse is safe iff the origin's diff tree equals what the recipient would compute. +That holds when **neither side has per-manager opaque marks** +(`self._opaqueRegistries.Count == 0` on both). **Global** opacity is shared by +every manager, so it shapes the origin's and the recipient's diff *identically* +and is safe to reuse across — which is why the gate keys on the per-manager +registry count, **not** `GetOpaqueCtx()` (the latter also reflects the +session-wide global count and would needlessly disable reuse). The flag rides on +the fan-out op as `OriginHasNoOpacity`; the recipient re-checks its own count. + +## Correctness invariants preserved + +- A recipient's shadow stays current even when Phase A skips its dispatch + (`Materialize` is never skipped), so writes it later originates fan out the + right `oldValue`. +- Phase B is byte-identical to the re-diff path when the gate holds: + `op.Diff` equals `Diff.diff(op.OldValue, op.NewValue)` under matching opacity, + so `DispatchDiffNode` emits the same events — only the recomputation is removed. +- Array fan-out (`ArrayInsert/Remove/Set`) is unchanged. + +## Implementing code + +- `Coverage.luau` — `IsLocallyObserved` (signals + `CoversChangesAt`, no + link-group clause); `IsTrackable` delegates to it. +- `TableManager.luau` — `_NotifyApplied` Set branch (Phase A skip + Phase B + reuse/fallback). +- `ChangeDetector.luau` — `DispatchDiffNode` + the shared `_emitRootNode` tail. +- `LinkGroup.luau` / `Emitter.luau` — thread the origin's diff node + + `OriginHasNoOpacity` through `FanOutSet` → `NotifyChange` → the applied op. +- `TMTypes.luau` — `Diff` / `OriginHasNoOpacity` on `RelativeOp` / `AppliedOp`. +- Tests: `Tests/TM/TableManager.link-fanout-perf.spec.luau`. + +## Phase C (deferred) — group-owned shadow + +The remaining per-member `Materialize` (one shadow copy per member per write) is +eliminated only by the `LinkGroup` owning **one** shadow for the shared region, +with members delegating shadow access (coordinate-translated) and the origin +reconciling it once. Safe in principle under single-owner semantics (no private +per-member copies ⇒ no aliasing) plus an "ask who cares" walk-depth rule for +asymmetric opacity, but a large change (delegation router, group-shadow lifecycle +across link/unlink/divergence, copy-on-leave). Not yet built. + +## Related: coalesced flushing (Part 1) + +A separate axis, `Config.FlushMode = "coalesced"` (`CoalescedFlush.luau`), defers +a write's `_doFlush` to frame-end and merges all flush requests into one +common-ancestor root — one diff/one firing per path per frame instead of per +write. Orthogonal to the fan-out optimization above. diff --git a/lib/tablemanager2/src/Docs/PROXY_USERDATA_NOTES.md b/lib/tablemanager2/src/Docs/PROXY_USERDATA_NOTES.md new file mode 100644 index 00000000..bcc1de8a --- /dev/null +++ b/lib/tablemanager2/src/Docs/PROXY_USERDATA_NOTES.md @@ -0,0 +1,193 @@ +# Proxy Userdata Implementation Notes + +## Overview +Proxies in TableManager are implemented as **userdatas** (via weak table tracking) rather than regular tables. This design provides several advantages but requires understanding of metamethod behavior. + +## What Works ✅ + +### 1. Length Operator +```lua +local tm = TableManager.new { items = {1, 2, 3} } +print(#tm.Proxy.items) -- Works! Returns 3 +``` +The `__len` metamethod returns `#meta.Original`, so the length operator works transparently. + +### 2. Generic Iteration +```lua +local tm = TableManager.new { a = 1, b = 2, c = 3 } +-- ❌ This does NOT work - pairs()and ipairs don't work on userdatas +for key, value in pairs(tm.Proxy) do + print(key, value) -- Won't work! +end + +-- ✅ Use generic for iteration instead +for key, value in tm.Proxy do + print(key, value) -- Works! Uses __iter metamethod +end +``` +The `__iter` metamethod enables generic for iteration on userdatas. +Generic for iteration works for both arrays and dictionaries. + +### 3. Indexing and Assignment +```lua +local tm = TableManager.new { data = {} } +tm.Proxy.data.key = "value" -- Works! Triggers __newindex +local val = tm.Proxy.data.key -- Works! Triggers __index +``` +Standard table operations work via `__index` and `__newindex` metamethods. + +### 4. Equality Comparison (proxy-to-proxy) +```lua +local shared = { x = 1 } +local tm1 = TableManager.new { shared = shared } +local tm2 = TableManager.new { shared = shared } +if tm1.Proxy.shared == tm2.Proxy.shared then + -- Works! Proxies wrapping same original are equal +end +``` +The `__eq` metamethod compares the underlying original tables. + +### 5. String Conversion +```lua +local tm = TableManager.new { nested = { deep = 1 } } +print(tostring(tm.Proxy.nested.deep)) +-- Prints: "TableManager.Data(nested.deep)" +``` +The `__tostring` metamethod provides readable proxy identification. + +## What Doesn't Work ❌ + +### 1. Direct table.* Functions on Proxies +```lua +-- ❌ DON'T DO THIS +local tm = TableManager.new { items = {} } +table.insert(tm.Proxy.items, "value") -- Won't work! items is a proxy, not a table +``` + +**Solution:** Use TableManager's methods instead: +```lua +-- ✅ DO THIS +tm:Insert({"items"}, "value") +``` + +### 2. Comparing Proxy to Original (using ==) +```lua +-- ❌ This doesn't work due to Lua/Luau limitation +local original = { a = 1 } +local tm = TableManager.new(original) +if tm.Proxy == original then -- Won't work! Different metatables + -- __eq only works when both operands have same metatable +end +``` + +**Solution:** Use the ProxyManager's `Equals` method: +```lua +-- ✅ DO THIS +if tm._proxyManager:Equals(tm.Proxy, original) then + -- This works! +end +``` + +### 3. rawget/rawset on Proxies +```lua +-- ❌ These bypass metamethods and won't work correctly +rawget(tm.Proxy, "key") +rawset(tm.Proxy, "key", "value") +``` +Since proxies are tracked via weak tables, raw operations may not behave as expected. + +### 4. Using Proxies as Table Keys +```lua +-- ❌ Proxies and originals are NOT interchangeable as table keys +local original = { id = 1 } +local tm = TableManager.new(original) +local lookup = {} + +lookup[original] = "value1" +lookup[tm.Proxy] -- Returns nil! Proxy is a different key than original + +lookup[tm.Proxy] = "value2" +lookup[original] -- Still "value1"! They are separate keys +``` + +**Why?** In Lua, table key lookup uses **raw identity**, not metamethod equality. Even though proxies have a `__eq` metamethod, Lua's table implementation doesn't use it for key comparison. A userdata proxy and its original table are **different objects with different identities**, so they cannot be used interchangeably as keys. + +**Solution:** Be consistent - always use either the proxy OR the original as keys, never mix them: +```lua +-- ✅ Consistent usage - use proxy everywhere +lookup[tm.Proxy] = "value" +print(lookup[tm.Proxy]) -- Works! + +-- ✅ Consistent usage - use original everywhere +lookup[original] = "value" +print(lookup[original]) -- Works! + +-- ❌ Mixed usage - doesn't work +lookup[original] = "value" +print(lookup[tm.Proxy]) -- Returns nil! +``` + +## Best Practices + +### 1. Always Use TableManager Methods for Array Modifications +```lua +-- ✅ Correct +tm:Insert({"inventory"}, item) +tm:Remove({"inventory"}, index) + +-- ❌ Incorrect +table.insert(tm.Proxy.inventory, item) -- Won't work +table.remove(tm.Proxy.inventory) -- Won't work +``` + +### 2. Use # Operator Freely for Length +```lua +-- ✅ This is fine! +local count = #tm.Proxy.items +if #tm.Proxy.inventory > 10 then + -- This works correctly +end +``` + +### 3. Iterate with Generic For as Normal +```lua +-- ✅ Generic for iteration works correctly +for key, value in tm.Proxy.config do + print(key, value) +end + +for i, item in tm.Proxy.items do + print(i, item) +end + +-- ❌ Don't use pairs() or ipairs() +-- for k, v in pairs(tm.Proxy.config) do end -- Won't work! +-- for i, v in ipairs(tm.Proxy.items) do end -- Won't work! +``` + +### 4. For Equality Checks with Originals, Use ProxyManager +```lua +-- ✅ Correct way to compare proxy with original +if tm._proxyManager:Equals(tm.Proxy.something, originalTable) then + -- This works +end + +-- Or unwrap first +if tm._proxyManager:GetOriginal(tm.Proxy.something) == originalTable then + -- This also works +end +``` + +## Summary + +The userdata proxy implementation provides transparent table-like behavior through metamethods: +- ✅ `#proxy` works via `__len` +- ✅ `for k, v in proxy do` works via `__iter` (generic for iteration) +- ✅ `proxy.key` and `proxy.key = value` work via `__index` and `__newindex` +- ✅ `proxy1 == proxy2` works via `__eq` +- ❌ `pairs(proxy)` doesn't work (use generic for: `for k, v in proxy do`) +- ❌ `ipairs(proxy)` doesn't work (use generic for: `for i, v in proxy do`) +- ❌ `table.*` functions don't work (use TableManager methods) +- ❌ `proxy == original` doesn't work (use ProxyManager:Equals) + +When in doubt, use the TableManager's built-in methods for modifications, and the ProxyManager's helper methods for comparisons. diff --git a/lib/tablemanager2/src/Emitter.luau b/lib/tablemanager2/src/Emitter.luau new file mode 100644 index 00000000..94aece12 --- /dev/null +++ b/lib/tablemanager2/src/Emitter.luau @@ -0,0 +1,388 @@ +--!strict +--[=[ + @ignore + @class Emitter + + The single emit/notify core for TableManager. Every change event — whether + from the diff-driven Set path (ChangeDetector callbacks) or the synthetic + array-op path — flows through `makeEmit`/`fireArrayOperation` so the + signal+listener dual-fire lives in exactly one place. +]=] + +--// Imports //-- +const PathHelpers = require("./PathHelpers") +const BatchUtilsModule = require("./BatchUtils") +const BatchFlushModule = require("./BatchFlush") +const ArrayDiffModule = require("./ArrayDiff") +const ChangeDetectorModule = require("./ChangeDetector") +const LinkGroupModule = require("./LinkGroup") +const CoverageModule = require("./Coverage") +const Diff = require("./Diff") +const TMTypes = require("./TMTypes") + +--// Types //-- +type PathArray = PathHelpers.PathArray +type ChangeMetadata = ChangeDetectorModule.ChangeMetadata +type MoveMetadata = ArrayDiffModule.MoveMetadata +type DiffNode = Diff.DiffNode +type TableManagerLike = TMTypes.TableManagerLike + +const createAncestorSnapshot = BatchUtilsModule.CreateAncestorSnapshot + +const Emitter = {} + +-- --------------------------------------------------------------------------- +-- SignalFireMode dispatch +-- --------------------------------------------------------------------------- + +-- 1-based index, within a signal's packed args, of its "OldValue"-style +-- payload arg -- the one a coalesced merge must preserve from the FIRST fire +-- in the window rather than overwrite with each subsequent fire's value. +-- Signals with no such arg (KeyAdded, ArrayInserted) return nil: every +-- position there is "latest wins", so a full overwrite is already correct. +const function getOldValueArgIndex(manager: TableManagerLike, signal: any): number? + if signal == manager.ValueChanged then + return 3 -- path, newValue, oldValue + elseif signal == manager.KeyChanged then + return 4 -- path, key, newValue, oldValue + elseif signal == manager.KeyRemoved then + return 3 -- path, key, oldValue + elseif signal == manager.ArrayRemoved then + return 3 -- basePath, index, oldValue + elseif signal == manager.ArraySet then + return 4 -- basePath, index, newValue, oldValue + end + return nil +end + +-- "coalesced" SignalFireMode: collapses repeated fires of the SAME signal +-- within a tick into a single `:Fire` call carrying the latest args, instead +-- of one call per fire. Implemented here (rather than as a `Signal` method) +-- because the runtime `Signal` module is resolved via `require("../Signal")`, +-- which Wally installs from the external `howmanysmall/better-signal` +-- package -- not the first-party `lib/signal` fork in this repo. A new method +-- added only to the fork would be missing on the actual installed dependency. +const function fireSignalCoalesced(manager: TableManagerLike, signal: any, ...: any) + if not manager._signalCoalesceState then + (manager :: any)._signalCoalesceState = {} + end + const state = manager._signalCoalesceState :: { [any]: { Args: { n: number, [number]: any } } } + + const entry = state[signal] + if entry then + const oldValueIndex = getOldValueArgIndex(manager, signal) + const preservedOldValue = if oldValueIndex then entry.Args[oldValueIndex] else nil + entry.Args = table.pack(...) + if oldValueIndex then + entry.Args[oldValueIndex] = preservedOldValue + end + return + end + + const newEntry = { Args = table.pack(...) } + state[signal] = newEntry + task.defer(function() + state[signal] = nil + signal:Fire(table.unpack(newEntry.Args, 1, newEntry.Args.n)) + end) +end + +-- Dispatches a fire on one of `manager`'s public per-change Signals according +-- to `manager._signalFireMode` (see `TMTypes.SignalFireMode`). +const function fireSignal(manager: TableManagerLike, signal: any, ...: any) + const mode = manager._signalFireMode + if mode == "deferred" then + signal:FireDeferred(...) + elseif mode == "bindable" then + signal:FireBindable(...) + elseif mode == "coalesced" then + fireSignalCoalesced(manager, signal, ...) + else + signal:Fire(...) + end +end + +-- --------------------------------------------------------------------------- +-- Synthetic metadata helpers +-- --------------------------------------------------------------------------- + +const function createSyntheticDiffNode( + kind: "added" | "removed" | "changed", + key: any, + newValue: any, + oldValue: any +): DiffNode + return { + type = kind, + new = newValue, + old = oldValue, + key = key, + } +end + +const function createSyntheticMetadata( + rootTable: any, + leafPath: PathArray, + kind: "added" | "removed" | "changed", + key: any, + newValue: any, + oldValue: any +): ChangeMetadata + return { + Diff = createSyntheticDiffNode(kind, key, newValue, oldValue), + OriginPath = leafPath, + OriginDiff = createSyntheticDiffNode(kind, key, newValue, oldValue), + Snapshot = createAncestorSnapshot(rootTable), + } +end + +-- --------------------------------------------------------------------------- +-- Core notification primitives +-- --------------------------------------------------------------------------- + +--[[ + Fires ancestor `ValueChanged` (and, when `includeKeyChanged` is not false, + `KeyChanged`) listeners from `basePath` up to the root, building a + "descendant changed" metadata for each ancestor. +]] +function Emitter.fireAncestorValueChangedNotifications( + manager: any, -- TableManagerLike (typed `any` to avoid Luau generic-recursive poison) + basePath: PathArray, + metadata: ChangeMetadata, + includeKeyChanged: boolean? +) + const ancestorMetadata: ChangeMetadata = { + Diff = nil, + OriginPath = metadata.OriginPath, + OriginDiff = metadata.OriginDiff, + Snapshot = metadata.Snapshot, + ArrayOp = metadata.ArrayOp, + } + + const keyChangedMode = if includeKeyChanged == false then "none" else "child" + + ChangeDetectorModule.EmitAncestorNotifications( + basePath :: { any }, + #basePath, + ancestorMetadata, + nil, + keyChangedMode, + function(path, key, newValue, oldValue, m) + manager._listenerRegistry:FireListenersExact("KeyChanged", path, { + Key = key, + NewValue = newValue, + OldValue = oldValue, + Metadata = m, + }) + end, + function(path, newValue, oldValue, m) + manager._listenerRegistry:FireListenersExact("ValueChanged", path, { + NewValue = newValue, + OldValue = oldValue, + Metadata = m, + }) + end + ) +end + +--[[ + Fires the appropriate `Array*` signal + exact-path listeners + ancestor + ValueChanged callbacks, and fans out to any linked managers. Must be + called AFTER the mutation so `manager.Raw` reflects the new state. +]] +function Emitter.fireArrayOperation( + manager: TableManagerLike, + eventName: "ArrayInserted" | "ArrayRemoved" | "ArraySet", + basePath: PathArray, + leafPath: PathArray, + payload: any +) + -- Tag metadata with explicit shift semantics for diff-tree consumers + -- (OriginDiff via root OnChange listeners). + payload.Metadata.ArrayOp = { Kind = eventName, Index = payload.Index } + + -- Fire at element path first (element-level listeners), then at array + -- path (array-level listeners), then ancestors (ValueChanged). + manager._listenerRegistry:FireListenersExact(eventName, leafPath, payload) + manager._listenerRegistry:FireListenersExact(eventName, basePath, payload) + Emitter.fireAncestorValueChangedNotifications(manager, basePath, payload.Metadata) + + if eventName == "ArrayInserted" then + fireSignal(manager, manager.ArrayInserted, basePath, payload.Index, payload.NewValue) + elseif eventName == "ArrayRemoved" then + fireSignal(manager, manager.ArrayRemoved, basePath, payload.Index, payload.OldValue) + elseif eventName == "ArraySet" then + fireSignal(manager, manager.ArraySet, basePath, payload.Index, payload.NewValue, payload.OldValue) + end + + if not manager._suppressLinkFanOut then + LinkGroupModule.FanOutArrayOp(manager, eventName, basePath, payload) + end + + -- Array ops are synthetic (never diffed against the shadow), but the + -- shadow still needs to end up reflecting the array's new contents so a + -- LATER flush at/above `basePath` (e.g. its parent table being replaced) + -- diffs against current data instead of a stale pre-op copy. Only + -- attempted when this op was actually trackable, so an unobserved + -- array's shadow stays untouched (no wasted copy); `Materialize` is + -- otherwise idempotent/safe whether or not `basePath` was already seeded. + -- Skipped entirely if `basePath` passes through an opaque ancestor — see + -- Coverage.PassesThroughOpaqueAncestor; the shadow can't safely persist + -- through one, so that array's shadow just stays unseeded. + if CoverageModule.IsTrackable(manager, basePath) then + const ctx = manager._changeDetector:GetOpaqueCtx() + if not CoverageModule.PassesThroughOpaqueAncestor(manager, basePath, ctx) then + manager._shadow:Materialize(basePath, manager.Raw, ctx) + end + end +end + +--[[ + Builds the `Emit` interface for a single array path, wiring the three + callbacks to fire `ArrayRemoved`/`ArrayInserted`/`ArraySet` signals, + exact-path listeners, and ancestor callbacks in the correct order. +]] +-- Return type is intentionally unannotated: the `set` callback accepts an +-- optional 4th `move` arg (used by ArraySwapRemove) beyond what +-- `ArrayDiff.Emit` declares, and the inferred type captures that correctly. +function Emitter.makeEmit(manager: TableManagerLike, path: PathArray) + return { + removed = function(index: number, oldValue: any, move: MoveMetadata?) + const removedPath: PathArray = PathHelpers.Append(path, index) + const metadata = createSyntheticMetadata(manager.Raw, removedPath, "removed", index, nil, oldValue) + metadata.Move = move + Emitter.fireArrayOperation(manager, "ArrayRemoved", path, removedPath, { + Index = index, + OldValue = oldValue, + Metadata = metadata, + }) + end, + inserted = function(index: number, newValue: any, move: MoveMetadata?) + const insertedPath = PathHelpers.Append(path, index) + const metadata = createSyntheticMetadata(manager.Raw, insertedPath, "added", index, newValue, nil) + metadata.Move = move + Emitter.fireArrayOperation(manager, "ArrayInserted", path, insertedPath, { + Index = index, + NewValue = newValue, + Metadata = metadata, + }) + end, + set = function(index: number, newValue: any, oldValue: any, move: MoveMetadata?) + const setPath = PathHelpers.Append(path, index) + const metadata = createSyntheticMetadata(manager.Raw, setPath, "changed", index, newValue, oldValue) + metadata.Move = move + Emitter.fireArrayOperation(manager, "ArraySet", path, setPath, { + Index = index, + NewValue = newValue, + OldValue = oldValue, + Metadata = metadata, + }) + end, + } +end + +--[[ + Builds the synthetic-metadata struct used by `ForceNotify` and by the + `onArrayAppended` fast path (for externally-supplied roots/paths/values). +]] +function Emitter.makeSyntheticMetadata( + rootTable: any, + leafPath: PathArray, + kind: "added" | "removed" | "changed", + key: any, + newValue: any, + oldValue: any +): ChangeMetadata + return createSyntheticMetadata(rootTable, leafPath, kind, key, newValue, oldValue) +end + +-- --------------------------------------------------------------------------- +-- ChangeDetector callback factory +-- --------------------------------------------------------------------------- + +--[[ + Builds the four ChangeDetector callbacks for a newly-constructed + TableManager, wrapping the signal+listener dual-fire in a single call site. + Replaces the ~80 lines of near-identical inline closures in + `TableManager.new`. +]] +function Emitter.makeChangeDetectorCallbacks(manager: TableManagerLike) + -- Suppresses non-array change events during batch array flush whose location + -- is at or under a tracked array path (see BatchFlush.ShouldSuppress). + local function shouldSuppress(location: PathArray): boolean + return BatchFlushModule.ShouldSuppress(manager :: any, location) + end + + return { + OnKeyAdded = function(path: PathArray, key: any, newValue: any, metadata: ChangeMetadata) + if shouldSuppress(PathHelpers.Append(path, key)) then + return + end + -- Signal before registry listeners: if a listener performs a re-entrant + -- write its nested signal fires after this one, keeping the public + -- signal stream in write-initiation order (deterministic for replication). + if metadata.Diff and metadata.Diff.type ~= "descendantChanged" then + fireSignal(manager, manager.KeyAdded, path, key, newValue) + end + manager._listenerRegistry:FireListenersExact("KeyAdded", path, { + NewValue = newValue, + Key = key, + Metadata = metadata, + }) + end, + + OnKeyRemoved = function(path: PathArray, key: any, oldValue: any, metadata: ChangeMetadata) + if shouldSuppress(PathHelpers.Append(path, key)) then + return + end + if metadata.Diff and metadata.Diff.type ~= "descendantChanged" then + fireSignal(manager, manager.KeyRemoved, path, key, oldValue) + end + manager._listenerRegistry:FireListenersExact("KeyRemoved", path, { + OldValue = oldValue, + Key = key, + Metadata = metadata, + }) + end, + + OnKeyChanged = function(path: PathArray, key: any, newValue: any, oldValue: any, metadata: ChangeMetadata) + if shouldSuppress(PathHelpers.Append(path, key)) then + return + end + if metadata.Diff and metadata.Diff.type ~= "descendantChanged" then + fireSignal(manager, manager.KeyChanged, path, key, newValue, oldValue) + end + manager._listenerRegistry:FireListenersExact("KeyChanged", path, { + NewValue = newValue, + OldValue = oldValue, + Key = key, + Metadata = metadata, + }) + end, + + OnValueChanged = function(path: PathArray, newValue: any, oldValue: any?, metadata: ChangeMetadata) + -- Suppress events at or under a tracked array path during batch array + -- flush; those arrays emit their own coalesced Array* events. + if shouldSuppress(path) then + return + end + -- Signal before listeners (see OnKeyAdded for rationale). + if metadata.Diff and metadata.Diff.type ~= "descendantChanged" then + fireSignal(manager, manager.ValueChanged, path, newValue, oldValue) + end + manager._listenerRegistry:FireListenersExact("ValueChanged", path, { + NewValue = newValue, + OldValue = oldValue, + Metadata = metadata, + }) + -- Link fan-out: only for leaf changes, never while replaying fan-out. + -- Pass the already-built diff node so recipients can replay it without + -- re-diffing (see LinkGroup.FanOutSet / TableManager._NotifyApplied). + if metadata.Diff and metadata.Diff.type ~= "descendantChanged" and not manager._suppressLinkFanOut then + LinkGroupModule.FanOutSet(manager, path, newValue, oldValue, metadata.Diff) + end + end, + } +end + +return Emitter diff --git a/lib/tablemanager2/src/ForMap.luau b/lib/tablemanager2/src/ForMap.luau new file mode 100644 index 00000000..59667c03 --- /dev/null +++ b/lib/tablemanager2/src/ForMap.luau @@ -0,0 +1,224 @@ +--!strict +--[=[ + @ignore + @class ForMap + + Shared identity-based reconcile drivers behind `ForKeys`/`ForValues`/`ForPairs` + and `MapKeys`/`MapValues`/`MapPairs` (see TMTypes.luau for the public semantics + table; TableManager.luau's For/Map methods are thin call-throughs into here). +]=] + +--// Imports //-- +const PathHelpers = require("./PathHelpers") +const JanitorModule = require("../Janitor") +const ChangeDetectorModule = require("./ChangeDetector") +const ListenerRegistryModule = require("./ListenerRegistry") +const TMTypes = require("./TMTypes") + +--// Types //-- +type PathArray = PathHelpers.PathArray +type Janitor = JanitorModule.Janitor +type ChangeMetadata = ChangeDetectorModule.ChangeMetadata +type Connection = ListenerRegistryModule.Connection +type ForOptions = TMTypes.ForOptions +type TM_Internal = TMTypes.TM_Internal + +const ForMap = {} + +type ForItem = { + Value: any, + Janitor: Janitor, +} + +type ValueItem = { + Janitor: Janitor, +} + +-- Wires the `OnChange` listener that drives `reconcile` and the initial fire +-- (deferred per `options.Defer`/the registry's fire-deferred mode, mirroring +-- `Observe`). Unlike `Observe`, the listener is registered BEFORE the initial +-- fire is scheduled: if the initial reconcile yields (a handler awaits +-- something), a change arriving mid-reconcile must still be observed instead +-- of silently lost. `reconcile` is idempotent against the live table, so a +-- redundant extra pass triggered by this ordering is harmless. +function ForMap.wireReconcileConnection( + self: TM_Internal, + parsedPath: PathArray, + reconcile: (metadata: ChangeMetadata?) -> (), + teardownAll: () -> (), + options: ForOptions?, + wrapInitialFire: ((fire: () -> ()) -> ())? +): Connection + const listenerConnection = self:OnChange(parsedPath, function(_newValue, _oldValue, metadata) + reconcile(metadata) + end, { ListenDepth = 1 }) + + if options == nil or options.FireForExisting ~= false then + const function fireInitial() + if wrapInitialFire then + wrapInitialFire(function() + reconcile(nil) + end) + else + reconcile(nil) + end + end + + if (options and options.Defer) or self._listenerRegistry._fireMode ~= "immediate" then + task.defer(fireInitial) + else + task.spawn(fireInitial) + end + end + + const connection = {} :: Connection + connection.Connected = true + connection.Disconnect = function(conn: Connection) + if not conn.Connected then + return + end + conn.Connected = false + listenerConnection:Disconnect() + teardownAll() + end + + return connection +end + +--[=[ + Shared driver for `ForKeys`/`ForPairs`/`MapKeys`/`MapValues`/`MapPairs`: + items are tracked BY KEY. When `recreateOnValueChange` is true + (`ForPairs`/`MapValues`/`MapPairs`), a value change at an existing key + tears down the old item and runs `onAdd` again for the new one. When false + (`ForKeys`/`MapKeys`), a value change at an existing key calls `onRefresh` + instead and the item (and its Janitor) stays alive. +]=] +function ForMap.makeKeyedReconciler( + self: TM_Internal, + parsedPath: PathArray, + recreateOnValueChange: boolean, + onAdd: (itemJanitor: Janitor, key: any, value: any, metadata: ChangeMetadata?) -> (), + onRemove: ((item: ForItem, key: any) -> ())?, + onRefresh: ((item: ForItem, key: any, newValue: any, metadata: ChangeMetadata?) -> ())?, + options: ForOptions?, + wrapInitialFire: ((fire: () -> ()) -> ())? +): Connection + const items: { [any]: ForItem } = {} + + const function destroyItem(key: any, item: ForItem) + items[key] = nil + if onRemove then + onRemove(item, key) + end + item.Janitor:Destroy() + end + + const function reconcile(metadata: ChangeMetadata?) + const current = self:Get(parsedPath, true) + + -- Removal pass first (safe to nil existing table keys mid `pairs`/`for...in`). + for key, item in items do + if type(current) ~= "table" or (current :: any)[key] == nil then + destroyItem(key, item) + end + end + + if type(current) ~= "table" then + return + end + + for key, value in current :: any do + const item = items[key] + if item == nil then + const itemJanitor = JanitorModule.new() + items[key] = { Value = value, Janitor = itemJanitor } + onAdd(itemJanitor, key, value, metadata) + elseif item.Value ~= value then + if recreateOnValueChange then + destroyItem(key, item) + const itemJanitor = JanitorModule.new() + items[key] = { Value = value, Janitor = itemJanitor } + onAdd(itemJanitor, key, value, metadata) + else + item.Value = value + if onRefresh then + -- luau is being dumb and thinks onRefresh is still nil for some reason + (onRefresh :: any)(item, key, value, metadata) + end + end + end + end + end + + const function teardownAll() + for key, item in items do + destroyItem(key, item) + end + end + + return ForMap.wireReconcileConnection(self, parsedPath, reconcile, teardownAll, options, wrapInitialFire) +end + +--[=[ + Shared driver for `ForValues`: items are tracked BY VALUE as a multiset, so + duplicate values are tracked as independent items and a value moving + between keys/indices does not re-run `onAdd`. +]=] +function ForMap.makeValueReconciler( + self: TM_Internal, + parsedPath: PathArray, + onAdd: (itemJanitor: Janitor, value: any, metadata: ChangeMetadata?) -> (), + options: ForOptions? +): Connection + const items: { [any]: { ValueItem } } = {} + + const function reconcile(metadata: ChangeMetadata?) + const current = self:Get(parsedPath, true) + const counts: { [any]: number } = {} + + if type(current) == "table" then + for _, value in current :: any do + counts[value] = (counts[value] or 0) + 1 + end + end + + for value, count in counts do + local list = items[value] + if list == nil then + list = {} + items[value] = list + end + while #list < count do + const itemJanitor = JanitorModule.new() + table.insert(list, { Janitor = itemJanitor }) + onAdd(itemJanitor, value, metadata) + end + while #list > count do + const item = table.remove(list) :: ValueItem + item.Janitor:Destroy() + end + end + + for value, list in items do + if counts[value] == nil then + for _, item in list do + item.Janitor:Destroy() + end + items[value] = nil + end + end + end + + const function teardownAll() + for _, list in items do + for _, item in list do + item.Janitor:Destroy() + end + end + table.clear(items) + end + + return ForMap.wireReconcileConnection(self, parsedPath, reconcile, teardownAll, options, nil) +end + +return ForMap diff --git a/lib/tablemanager2/src/IgnoreTrie.luau b/lib/tablemanager2/src/IgnoreTrie.luau new file mode 100644 index 00000000..82348204 --- /dev/null +++ b/lib/tablemanager2/src/IgnoreTrie.luau @@ -0,0 +1,63 @@ +--!strict +--[=[ + @ignore + @class IgnoreTrie + + A per-manager prefix trie of paths whose writes should skip the + diff/snapshot/event machinery entirely (`TableManagerConfig.IgnoredPaths`, + `TableManager:SetPathIgnored`). Marking is purely structural — literal + path segments, no wildcards — so prefix queries are O(depth). + + A leaf module (only depends on `PathHelpers`) so both `Mutator` (the + write-side pre-prune) and `ChangeDetector` (the diff-tree descendant + prune) can require it without creating a cycle through `Emitter`. +]=] + +local PathHelpers = require("./PathHelpers") + +const IGNORED_KEY = newproxy() +export type Node = { [any]: Node } + +local IgnoreTrie = {} + +function IgnoreTrie.new(): Node + return {} :: any +end + +function IgnoreTrie.SetPathIgnored(root: Node, path: PathHelpers.Path, ignored: boolean) + local parsedPath = PathHelpers.ParsePath(path) + local node = root + for _, segment in parsedPath do + local child = node[segment] + if child == nil then + child = {} + node[segment] = child :: any + end + node = child + end + node[IGNORED_KEY] = if ignored then true else nil +end + +-- `path` must already be a parsed PathArray (no string-path parsing here — +-- both call sites are on hot paths and already hold one). +function IgnoreTrie.IsPathIgnored(root: Node?, path: { any }): boolean + if root == nil then + return false + end + local node: Node? = root + if node and node[IGNORED_KEY] then + return true + end + for _, segment in path do + if node == nil then + return false + end + node = node[segment] + if node and node[IGNORED_KEY] then + return true + end + end + return false +end + +return IgnoreTrie diff --git a/lib/tablemanager2/src/IsDeferred.luau b/lib/tablemanager2/src/IsDeferred.luau new file mode 100644 index 00000000..283dc631 --- /dev/null +++ b/lib/tablemanager2/src/IsDeferred.luau @@ -0,0 +1,27 @@ +--!native +--!optimize 2 +--!strict +-- This file just checks if the current environment defers events naturally or not. + +local cached: boolean? = nil + +const function IsDeferred(): boolean + if cached ~= nil then + return cached + end + + local handlerRun = false + pcall(function() + const bindableEvent = Instance.new("BindableEvent") + bindableEvent.Event:Once(function() + handlerRun = true + end) + bindableEvent:Fire() + bindableEvent:Destroy() + end) + + cached = not handlerRun + return cached :: any +end + +return IsDeferred diff --git a/lib/tablemanager2/src/LinkGroup.luau b/lib/tablemanager2/src/LinkGroup.luau new file mode 100644 index 00000000..e525fa44 --- /dev/null +++ b/lib/tablemanager2/src/LinkGroup.luau @@ -0,0 +1,733 @@ +--!strict +--[=[ + @ignore + @class LinkGroup + + Identity-keyed groups of TableManager "anchors" that share one underlying + table by object identity. A write applied through any member's pipeline is + fanned out (notify-only, never re-mutated) to every other current member of + the group, translated into that member's own path coordinates. + + Membership is owned by the group, not by any individual TableManager, so + destroying/unlinking one member never breaks the link between the others + (no relay chains). +]=] + +local PathHelpers = require("./PathHelpers") +local Signal = require("../Signal") +local TMTypes = require("./TMTypes") +local Diff = require("./Diff") + +type PathArray = PathHelpers.PathArray +type Path = PathHelpers.Path +type DiffNode = Diff.DiffNode +type Signal = Signal.Signal + +--// Types //-- + +-- `TableManagerLike` (= `TM_Internal`), `AppliedOp`, `RelativeOp`, +-- `ArrayOpPayload`, `LinkAnchor`, and `LinkGroup` are defined in `TMTypes` +-- (a leaf module), so TableManager.luau can reference the real types here +-- without a circular require. +type TableManagerLike = TMTypes.TableManagerLike +export type AppliedOp = TMTypes.AppliedOp +type RelativeOp = TMTypes.RelativeOp +export type ArrayOpPayload = TMTypes.ArrayOpPayload +export type LinkAnchor = TMTypes.LinkAnchor + +type Member = { + Manager: TableManagerLike, -- TM_Internal + AnchorPath: PathArray, + AnchorProxy: any?, +} + +type opKind = "Set" | "ArrayInsert" | "ArrayRemove" | "ArraySet" + +export type LinkGroup = TMTypes.LinkGroup + +-------------------------------------------------------------------------------- +--// Module //-- +-------------------------------------------------------------------------------- + +local LinkGroup = {} +local LinkGroup_MT = { __index = LinkGroup } + +-- Weak identity-keyed registry: object (shared table) -> LinkGroup. +local OBJECT_TO_GROUP: { [any]: LinkGroup } = setmetatable({}, { __mode = "k" }) :: any + +local function resolveAnchorPath(path: Path?): PathArray + if path == nil or path == "" then + return {} + end + return PathHelpers.ParsePath(path) +end + +--[=[ + @within LinkGroup + + Removes `manager` from `group`'s membership bookkeeping (both directions), + fires `MemberRemoved`, and dissolves the group if membership drops below 2. +]=] +local function removeMember(self: LinkGroup, manager: TableManagerLike): boolean + -- Drop manager's membership record(s) from the group's side. + local removedAny = false + for i = #self._members, 1, -1 do + if self._members[i].Manager == manager then + table.remove(self._members, i) + removedAny = true + end + end + + if not removedAny then + return false + end + + -- Drop the group from manager's own membership list (the reverse edge). + local groups = manager._linkGroups + if groups then + for i = #groups, 1, -1 do + if groups[i] == self then + table.remove(groups, i) + end + end + end + + self.MemberRemoved:Fire(manager) + + -- A group of fewer than 2 members has nothing left to fan out to. + if #self._members < 2 then + self:Destroy() + end + + return true +end + +--[=[ + Joins (or updates) `manager`'s anchor on `self`. Asserts that `manager:Get(path)` + currently resolves to the group's shared object. Idempotent: re-adding the same + manager updates its anchor path instead of duplicating membership. +]=] +function LinkGroup.Add(self: LinkGroup, manager: TableManagerLike, path: Path?) + -- Validate the anchor: it must currently resolve to the group's shared object. + local parsedPath = resolveAnchorPath(path) + local value = manager:Get(parsedPath) + assert(value == self._object, "Link anchor does not resolve to the shared object") + + -- If the anchor resolves through a proxy, remember it so GetAnchorPath can + -- report the *live* path even after array shifts/MoveTo/Swap. + local anchorProxy: any? = nil + local proxyManager = manager._proxyManager + if proxyManager and type(value) == "table" then + local proxy = manager:GetProxy(parsedPath, true) + if proxyManager:IsProxy(proxy) then + anchorProxy = proxy + end + end + + -- Idempotent re-add: update the existing member's anchor instead of duplicating it. + for _, member in self._members do + if member.Manager == manager then + member.AnchorPath = parsedPath + member.AnchorProxy = anchorProxy + return + end + end + + -- New member: record both directions of the membership edge. + table.insert(self._members, { Manager = manager, AnchorPath = parsedPath, AnchorProxy = anchorProxy }) + + local groups = manager._linkGroups + if groups == nil then + groups = {} + manager._linkGroups = groups + end + table.insert(groups :: { LinkGroup }, self) +end + +--[=[ + Removes `manager`'s anchor from the group. No-op if `manager` is not a member. +]=] +function LinkGroup.Remove(self: LinkGroup, manager: TableManagerLike) + removeMember(self, manager) +end + +--[=[ + Returns the live managers currently in this group. +]=] +function LinkGroup.Members(self: LinkGroup): { TableManagerLike } + local result = table.create(#self._members) :: { TableManagerLike } + for _, member in self._members do + table.insert(result, member.Manager) + end + return result +end + +function LinkGroup.HasMember(self: LinkGroup, manager: TableManagerLike): boolean + for _, member in self._members do + if member.Manager == manager then + return true + end + end + return false +end + +--[=[ + Returns the live path (in `manager`'s coordinates) to the shared object, or + `nil` if `manager` is not a member. Uses the manager's own proxy graph (if + available) so the anchor stays correct across array shifts/MoveTo/Swap. +]=] +function LinkGroup.GetAnchorPath(self: LinkGroup, manager: TableManagerLike): PathArray? + for _, member in self._members do + if member.Manager == manager then + if member.AnchorProxy ~= nil then + local proxyManager = manager._proxyManager + if proxyManager then + local livePath = proxyManager:GetPath(member.AnchorProxy) + if livePath ~= nil then + return livePath + end + end + end + return member.AnchorPath + end + end + return nil +end + +--[=[ + Fans `op` (relative to the shared object) out to every other current member, + translating into each member's own coordinates and replaying it via + `_NotifyApplied`. Isolates each member's delivery in `pcall` so one throwing + listener can't block delivery to the rest. Snapshots the member list so a + member that leaves mid-dispatch (e.g. divergence detected during its own + notify) doesn't corrupt iteration. +]=] +function LinkGroup.NotifyChange(self: LinkGroup, originManager: TableManagerLike, op: RelativeOp) + -- Snapshot membership: a member's _NotifyApplied may itself trigger a + -- divergence that removes a member mid-dispatch. + local members = table.clone(self._members) + for _, member in members do + local manager = member.Manager + if manager == originManager then + continue + end + if manager._Destroyed then + continue + end + if not self:HasMember(manager) then + continue + end + + local anchorPath = self:GetAnchorPath(manager) + if anchorPath == nil then + continue + end + + -- Translate the op from "relative to the shared object" into this + -- member's own root-relative coordinates. + local translatedPath: PathArray = table.clone(anchorPath) + for _, segment in op.RelativePath do + table.insert(translatedPath, segment) + end + + local appliedOp: AppliedOp = { + Kind = op.Kind, + Path = translatedPath, + NewValue = op.NewValue, + OldValue = op.OldValue, + Index = op.Index, + -- Phase B: carry the origin's diff node (and its opacity state) so the + -- recipient can replay it without re-diffing (see _NotifyApplied). + Diff = op.Diff, + OriginHasNoOpacity = op.OriginHasNoOpacity, + } + + -- Isolate each member's delivery: a throwing listener on one member + -- must not block fan-out to the rest. + local ok, err = pcall(manager._NotifyApplied, manager, appliedOp) + if not ok then + task.spawn(error, err, 0) + end + end +end + +--[=[ + Tears down the group's wiring. Does NOT destroy member TableManagers. +]=] +function LinkGroup.Destroy(self: LinkGroup) + if self._destroyed then + return + end + self._destroyed = true + + -- Unlink every remaining member's reverse edge before clearing membership. + for _, member in table.clone(self._members) do + local groups = member.Manager._linkGroups + if groups then + for i = #groups, 1, -1 do + if groups[i] == self then + table.remove(groups, i) + end + end + end + end + table.clear(self._members) + + -- Drop the canonical registration so a future Link on this object starts fresh. + OBJECT_TO_GROUP[self._object] = nil + + self.Dissolved:Fire() + self.MemberRemoved:Destroy() + self.Dissolved:Destroy() +end + +-------------------------------------------------------------------------------- +--// Static Helpers //-- +-------------------------------------------------------------------------------- + +-- Constructs a fresh, empty group for `object`. Not registered in +-- OBJECT_TO_GROUP here; callers register via getOrCreateGroup. +local function newGroup(object: any): LinkGroup + local self = setmetatable({}, LinkGroup_MT) :: any + self._object = object + self._members = {} + self._destroyed = false + self.MemberRemoved = Signal.new() :: any + self.Dissolved = Signal.new() :: any + return self +end + +-- Returns the canonical group for `object`'s identity, creating it if needed. +local function getOrCreateGroup(object: any): LinkGroup + local group = OBJECT_TO_GROUP[object] + if group == nil then + group = newGroup(object) + OBJECT_TO_GROUP[object] = group + end + return group +end + +--[=[ + @within LinkGroup + + `TableManager.Link` implementation. Two call forms: + - `Link({ {manager, path?}, {manager, path?}, ... })` + - `Link(managerA, pathA, managerB, pathB)` + + All anchors must resolve to the same shared object (by identity); they are + joined into that object's canonical `LinkGroup`. + + @param ... Either a single list of `{manager, path?}` anchors, or pairs of `manager, path?` arguments. +]=] +local function link(...): LinkGroup + local argCount = select("#", ...) + + -- Normalize both call forms into a single list of { manager, path? } anchors. + local anchors: { LinkAnchor } + if argCount == 1 then + anchors = (...) :: any + else + local a, pathA, b, pathB = ... + anchors = { { a, pathA }, { b, pathB } } + end + + assert(#anchors >= 2, "Link requires at least 2 anchors") + + -- The first anchor's resolved value defines the shared object's identity + -- and therefore which canonical LinkGroup every anchor will join. + local firstManager = anchors[1][1] :: TableManagerLike + local firstPath = anchors[1][2] :: Path? + local object = firstManager:Get(resolveAnchorPath(firstPath), true) + assert(type(object) == "table", "Link anchors must resolve to a shared table") + + -- Validate and join every anchor; Add asserts identity again per-anchor. + local group = getOrCreateGroup(object) + for _, anchor in anchors do + local manager = anchor[1] :: TableManagerLike + local path = anchor[2] :: Path? + local value = manager:Get(resolveAnchorPath(path), true) + assert(value == object, "All Link anchors must resolve to the same shared object") + group:Add(manager, path) + end + + return group +end + +--[=[ + @within LinkGroup + + Identity-based divergence check, called directly from `applyWrite` right + after a write is applied (before change detection runs). If `writePath` is + at or above one of `self`'s link anchors and that anchor no longer resolves + to its group's shared object (the reference was replaced), that anchor + leaves the group. + + This is independent of the ChangeDetector diff shape: a wholesale + reassignment of a table to a structurally-similar table is reported by + ChangeDetector as "descendantChanged" (a deep structural diff), not as an + identity replacement, so divergence cannot be reliably detected from the + OnValueChanged diff metadata alone. +]=] +local function checkDivergence(self: TableManagerLike, writePath: PathArray) + local groups = self._linkGroups + if groups == nil then + return + end + + for _, group in table.clone(groups) do + local anchorPath = group:GetAnchorPath(self) + if anchorPath == nil then + continue + end + + -- Only anchors at-or-above the write could have had their identity replaced. + if PathHelpers.IsPrefixPath(writePath, anchorPath) then + if self:Get(anchorPath, true) ~= group._object then + -- The anchor no longer points at the shared object: sever this + -- member only. The rest of the group keeps the original object. + group:Remove(self) + end + end + end +end + +--[=[ + @within LinkGroup + + Called when `self`'s root table identity has been replaced + (`Set({}, newRoot)`). The manager's cached anchor proxies pointed into the + now-pruned old tree, so clear them (GetAnchorPath then falls back to the + static AnchorPath), then run the standard divergence check at the root: any + anchor that no longer resolves to its group's shared object is severed. This + is the `{}` generalisation of the ancestor-replacement handling in + `checkDivergence`. +]=] +local function onRootReplaced(self: TableManagerLike) + local groups = self._linkGroups + if groups == nil then + return + end + + for _, group in table.clone(groups) do + for _, member in group._members do + if member.Manager == self then + member.AnchorProxy = nil + break + end + end + end + + checkDivergence(self, {}) +end + +--[=[ + @within LinkGroup + + Fan-out hook for "Set"-kind changes (called from the OnValueChanged + ChangeDetector callback). `path` is the changed location, in `self`'s own + coordinates. +]=] +local function fanOutSet(self: TableManagerLike, path: PathArray, newValue: any, oldValue: any, diffNode: DiffNode?) + local groups = self._linkGroups + if groups == nil then + return + end + + -- Phase B: a recipient may only reuse `diffNode` when neither side has any + -- PER-MANAGER opaque marks. Global opacity is shared by every manager, so it + -- shapes the origin's and the recipient's diff identically and is safe to + -- reuse across — hence we check the per-manager registry count, NOT + -- `GetOpaqueCtx()` (which also reflects the session-wide global count and + -- would needlessly disable reuse). Recipients re-check their own count in + -- `_NotifyApplied`. + local originHasNoOpacity = self._opaqueRegistries.Count == 0 + + for _, group in table.clone(groups) do + local anchorPath = group:GetAnchorPath(self) + if anchorPath == nil then + continue + end + + if PathHelpers.IsPrefixPath(path, anchorPath) then + -- `path` is the anchor itself or an ancestor of it: the shared + -- object's reference at this anchor may have changed. Check + -- liveness; if it diverged, this anchor leaves the group (the + -- remaining members still share the original object untouched). + if self:Get(anchorPath, true) ~= group._object then + group:Remove(self) + end + elseif PathHelpers.IsPrefixPath(anchorPath, path) then + -- `path` is strictly inside the anchor: compute the change's + -- location relative to the shared object and fan it out. + local relativePath: PathArray = {} + for i = #anchorPath + 1, #path do + table.insert(relativePath, path[i]) + end + group:NotifyChange(self, { + Kind = "Set", + RelativePath = relativePath, + NewValue = newValue, + OldValue = oldValue, + Diff = diffNode, + OriginHasNoOpacity = originHasNoOpacity, + }) + end + end +end + +--[=[ + @within LinkGroup + + Fan-out hook for array-op changes (called from `fireArrayOperation`). + `basePath` is the array's own path, in `self`'s own coordinates. +]=] +local function fanOutArrayOp( + self: TableManagerLike, + eventName: "ArrayInserted" | "ArrayRemoved" | "ArraySet", + basePath: PathArray, + payload: ArrayOpPayload +) + local groups = self._linkGroups + if groups == nil then + return + end + + -- Translate the event name into the AppliedOp vocabulary shared with fanOutSet. + local kind: opKind = if eventName == "ArrayInserted" + then "ArrayInsert" + elseif eventName == "ArrayRemoved" then "ArrayRemove" + else "ArraySet" + + for _, group in table.clone(groups) do + local anchorPath = group:GetAnchorPath(self) + if anchorPath == nil then + continue + end + + -- relativePath stays nil unless the array itself IS (or contains) the + -- shared object, i.e. the anchor is at or above basePath. + local relativePath: PathArray? = nil + if PathHelpers.ArePathsEqual(anchorPath, basePath) then + relativePath = {} + elseif PathHelpers.IsPrefixPath(anchorPath, basePath) then + relativePath = {} + for i = #anchorPath + 1, #basePath do + table.insert(relativePath :: PathArray, basePath[i]) + end + end + + if relativePath == nil then + -- Anchor lives inside this array (or is unrelated). Index shifts to + -- an anchor nested in the array are this manager's own bookkeeping + -- (handled by ShiftKeys against its own proxy graph) and don't + -- change the shared object's identity or content, so no fan-out. + continue + end + + group:NotifyChange(self, { + Kind = kind, + RelativePath = relativePath, + NewValue = payload.NewValue, + OldValue = payload.OldValue, + Index = payload.Index, + }) + end +end + +-------------------------------------------------------------------------------- +--// Auto-Link //-- +-- +-- Opt-in (`Config.AutoLink = true`) automatic linking for the "nested window" +-- pattern, where one TableManager's root table is a node inside another +-- TableManager's tree. Every auto-link anchor is a whole manager's *root*, so +-- the registry below is keyed by root identity only (one entry per manager), +-- not by every nested table. +-------------------------------------------------------------------------------- + +-- Weak-VALUED registry: root table -> the AutoLink manager that owns it. When a +-- manager is collected its entry vanishes (and the root key with it), so the +-- registry never pins a manager or its data alive. `UnregisterAutoLink` clears +-- entries promptly on Destroy. +local ROOT_TO_AUTOLINK_TM: { [{ [any]: any }]: TableManagerLike? } = setmetatable({}, { __mode = "kv" }) :: any + +-- Returns the live AutoLink manager registered for `object`, or nil if none / +-- the registrant has been destroyed. +local function autoLinkManagerFor(object: any): TableManagerLike? + local tm = ROOT_TO_AUTOLINK_TM[object] + if tm == nil or tm._Destroyed then + return nil + end + return tm +end + +-- Depth-first search of every table reachable from `root`, invoking +-- `visit(node, path)` for each table node (including `root` itself, at path {}). +-- `path` is fresh per call (safe to keep). Cycle-guarded via a seen-set. +local function dfsTables(root: any, visit: (node: { [any]: any }, path: PathArray) -> ()) + if type(root) ~= "table" then + return + end + local seen: { [any]: true } = {} + local function recurse(node: any, path: PathArray) + if type(node) ~= "table" or seen[node] then + return + end + seen[node] = true + visit(node, path) + for key, child in node do + if type(child) == "table" then + recurse(child, PathHelpers.Append(path, key)) + end + end + end + recurse(root, {}) +end + +-- Returns the path from `root` to the first node whose identity is `target`, +-- or nil if `target` is not reachable. `{}` when `root == target`. +local function findPathOf(root: any, target: any): PathArray? + if type(root) ~= "table" then + return nil + end + local seen: { [any]: true } = {} + local function recurse(node: any, path: PathArray): PathArray? + if type(node) ~= "table" or seen[node] then + return nil + end + seen[node] = true + if node == target then + return path + end + for key, child in node do + if type(child) == "table" then + local found = recurse(child, PathHelpers.Append(path, key)) + if found ~= nil then + return found + end + end + end + return nil + end + return recurse(root, {}) +end + +--[=[ + @within LinkGroup + + Downward pass: scans `tm`'s subtree at `path` (default root) for table nodes + that are the root of *another* AutoLink manager, and links each at its found + location. Recurses fully, so roots nested inside an already-found child are + also linked (overlapping groups are fine — a manager can belong to many). + Backs `Linker:Ensure`; also run at construction over the root. +]=] +local function findChildLinks(tm: TableManagerLike, path: Path?) + local basePath = resolveAnchorPath(path) + local subtree = tm:Get(basePath, true) + if type(subtree) ~= "table" then + return + end + + dfsTables(subtree, function(node, relPath) + local child = autoLinkManagerFor(node) + if child == nil or child == tm then + return + end + local anchorPath = table.clone(basePath) + for _, segment in relPath do + table.insert(anchorPath, segment) + end + link { { tm, anchorPath }, { child } } + end) +end + +--[=[ + @within LinkGroup + + Upward pass for the child-created-after-parent case: if `tm`'s own root sits + inside an already-registered AutoLink manager `Y`, link them at that location. + Uses Y's proxy graph for the path when available, else a bounded DFS of Y. +]=] +local function findParentLinks(tm: TableManagerLike) + local root = tm:Get({}, true) + if type(root) ~= "table" then + return + end + + for _, parent in ROOT_TO_AUTOLINK_TM do + if parent == tm or parent._Destroyed then + continue + end + + -- Cheap path first: ask the parent's proxy graph if it already knows + -- `root`; fall back to a bounded DFS of the parent's raw tree. + local anchorPath: PathArray? = nil + local proxyManager = parent._proxyManager + if proxyManager then + local existing = proxyManager:GetProxyFromOriginal(root) + if existing ~= nil then + anchorPath = proxyManager:GetPath(existing) + end + end + if anchorPath == nil then + anchorPath = findPathOf(parent:Get({}, true), root) + end + + if anchorPath ~= nil then + link { { parent, anchorPath }, { tm } } + end + end +end + +--[=[ + @within LinkGroup + + Registers `tm` as an AutoLink manager and forms any links implied by current + tree topology — both directions: `tm` nested inside an existing manager + (`findParentLinks`) and existing managers nested inside `tm` (`findChildLinks`). + Registration happens last so the walks never match `tm` against itself. +]=] +local function registerAutoLink(tm: TableManagerLike) + findParentLinks(tm) + findChildLinks(tm, nil) + ROOT_TO_AUTOLINK_TM[tm:Get({}, true)] = tm +end + +-- Drops `tm`'s registry entry. Called from TableManager.Destroy. +local function unregisterAutoLink(tm: TableManagerLike) + local root = tm:Get({}, true) + if type(root) == "table" then + ROOT_TO_AUTOLINK_TM[root] = nil + end +end + +--[=[ + @within LinkGroup + + O(1) direct write-path hook: if a *directly assigned* table value is the root + of another AutoLink manager, link it at the write location. Only the assigned + value itself is checked (no subtree scan) — roots buried inside a composite + are the `Linker:Ensure` case. Called from `applyWrite` for AutoLink managers. +]=] +local function checkAutoLink(tm: TableManagerLike, writePath: PathArray, value: any) + if type(value) ~= "table" then + return + end + local other = autoLinkManagerFor(value) + if other ~= nil and other ~= tm then + link { { tm, writePath }, { other } } + end +end + +return { + Link = link, + GetOrCreateGroup = getOrCreateGroup, + CheckDivergence = checkDivergence, + OnRootReplaced = onRootReplaced, + FanOutSet = fanOutSet, + FanOutArrayOp = fanOutArrayOp, + RegisterAutoLink = registerAutoLink, + UnregisterAutoLink = unregisterAutoLink, + FindChildLinks = findChildLinks, + CheckAutoLink = checkAutoLink, + FindPathOf = findPathOf, +} diff --git a/lib/tablemanager2/src/Linker.luau b/lib/tablemanager2/src/Linker.luau new file mode 100644 index 00000000..aa51219d --- /dev/null +++ b/lib/tablemanager2/src/Linker.luau @@ -0,0 +1,157 @@ +--!strict +--[=[ + @class Linker + + Per-instance facade hosting all linking operations for a TableManager, + reachable via `tm.Linker`. Hosts the linking methods so they do not crowd the + core TableManager surface; the heavy state (`_linkGroups`) and the hot-path + fan-out / auto-link hooks live on the manager itself, so the zero-ceremony + `AutoLink` path never goes through this facade. +]=] + +const LinkGroupModule = require("./LinkGroup") +const PathHelpers = require("./PathHelpers") +const TMTypes = require("./TMTypes") + +type Path = PathHelpers.Path +type LinkGroup = LinkGroupModule.LinkGroup + +-- `Linker` and `TableManagerLike` (= `TM_Internal`) are defined in +-- `TMTypes` (a leaf module), so this module can reference the real +-- TableManager type without a circular require with TableManager.luau, which +-- requires this module to build `self.Linker`. +type TableManagerLike = TMTypes.TableManagerLike +export type Linker = TMTypes.Linker + +-- Implementation-side view of `Linker`, including the back-reference to the +-- host TableManager. `Linker` itself never exposes `_tm`. +type LinkerImpl = Linker & { _tm: TableManagerLike } + +-------------------------------------------------------------------------------- +--// Module //-- +-------------------------------------------------------------------------------- + +const Linker = {} +const Linker_MT = { __index = Linker } + +--[=[ + @function new + @within Linker + @private + + Constructs the lazy `Linker` facade for `tm`. Allocated once at + construction; negligible next to the other per-instance state. +]=] +const function new(tm: TableManagerLike): Linker + return setmetatable({ _tm = tm }, Linker_MT) :: any +end + +--[=[ + @within Linker + Links this manager to `other` so writes fan out between them. With no paths, + both anchors are the managers' roots; pass `selfPath`/`otherPath` to link + interior nodes that resolve to the same shared table by identity. +]=] +function Linker.With(self: LinkerImpl, other: any, selfPath: Path?, otherPath: Path?) + LinkGroupModule.Link({ { self._tm :: any, selfPath }, { other, otherPath } }) +end + +--[=[ + @within Linker + Scans this manager's subtree at `path` (default root) and links any node that + is the root of another `AutoLink` manager. The explicit, recursive form of the + direct write-path auto-link; use after a nested/bulk insert. Idempotent. +]=] +function Linker.Ensure(self: LinkerImpl, path: Path?) + LinkGroupModule.FindChildLinks(self._tm :: any, path) +end + +--[=[ + @within Linker + Removes this manager from `target`: `nil` → all of its groups; a manager → + every group also containing that manager; a `LinkGroup` → just that group. +]=] +function Linker.Unlink(self: LinkerImpl, target: any?) + const tm = self._tm + const groups = tm._linkGroups + + -- Whole detach (no target). + if target == nil then + if groups == nil then + return + end + for _, g in table.clone(groups) do + g:Remove(tm :: any) + end + return + end + + -- A LinkGroup carries `_members`; anything else is treated as a manager. + if type(target) == "table" and (target :: any)._members ~= nil then + (target :: any):Remove(tm :: any) + return + end + + if groups == nil then + return + end + for _, g in table.clone(groups) do + if g:HasMember(target) then + g:Remove(tm :: any) + end + end +end + +--[=[ + @within Linker + Returns the managers currently linked to this one (de-duplicated across + groups, excluding self). +]=] +function Linker.GetManagers(self: LinkerImpl): { any } + const tm = self._tm + const result = {} + const groups = tm._linkGroups + if groups == nil then + return result + end + const seen: { [any]: true } = {} + for _, g in groups do + for _, m in g:Members() do + if m ~= tm and not seen[m] then + seen[m] = true + table.insert(result, m) + end + end + end + return result +end + +--[=[ + @within Linker + Returns true if this manager shares a group with `other`. +]=] +function Linker.IsLinkedWith(self: LinkerImpl, other: any): boolean + const groups = self._tm._linkGroups + if groups == nil then + return false + end + for _, g in groups do + if g:HasMember(other) then + return true + end + end + return false +end + +--[=[ + @within Linker + Advanced: the underlying `LinkGroup`s this manager participates in (a copy). +]=] +function Linker.GetGroups(self: LinkerImpl): { LinkGroup } + const groups = self._tm._linkGroups + return if groups then table.clone(groups) else {} +end + +return { + new = new, +} diff --git a/lib/tablemanager2/src/ListenerRegistry.luau b/lib/tablemanager2/src/ListenerRegistry.luau new file mode 100644 index 00000000..3dcf5f13 --- /dev/null +++ b/lib/tablemanager2/src/ListenerRegistry.luau @@ -0,0 +1,782 @@ +--!strict +--[=[ + @ignore + @class ListenerRegistry + + Clean implementation with ListenDepth filtering support using a tree structure. + + ## Features + - Tree-based storage: O(path_length) lookup instead of O(total_listeners) + - ListenDepth option: Control how many descendant levels trigger a listener + - ListenDepthStyle option: \"<=\" (within depth) or \"==\" (exact depth only) + - Once option: Auto-disconnect after first fire + - Unified metadata structure: All events include ChangeMetadata + - No ancestor tree-walk in the registry — ChangeDetector handles propagation + + ## Examples + + ### Example 1: Basic Tree Structure + ```lua + -- Register listeners: + registry:RegisterListener("ValueChanged", {}, callback1) -- Root listener + registry:RegisterListener("ValueChanged", {"Players"}, callback2) + registry:RegisterListener("ValueChanged", {"Players", "Player1"}, callback3) + registry:RegisterListener("ValueChanged", {"Players", "Player1", "Health"}, callback4) + + -- Tree structure: + -- [ROOT] + -- └─ Listeners: [callback1] + -- └─ Children: + -- └─ "Players" + -- └─ Listeners: [callback2] + -- └─ Children: + -- └─ "Player1" + -- └─ Listeners: [callback3] + -- └─ Children: + -- └─ "Health" + -- └─ Listeners: [callback4] + ``` + + ### Example 2: Change at {"Players", "Player1", "Health"} + ```lua + -- Fire: FireListenersExact("ValueChanged", {"Players", "Player1", "Health"}, eventData) + + -- Listeners that fire (in order): + -- 1. callback1 (root, no ListenDepth limit) - Ancestor of change + -- 2. callback2 ({"Players"}, no ListenDepth limit) - Ancestor of change + -- 3. callback3 ({"Players", "Player1"}, no ListenDepth limit) - Ancestor of change + -- 4. callback4 ({"Players", "Player1", "Health"}) - EXACT match (always fires) + + -- Traversal path: + -- [ROOT] (fire callback1 if depth filter passes) + -- → "Players" (fire callback2 if depth filter passes) + -- → "Player1" (fire callback3 if depth filter passes) + -- → "Health" (fire callback4 - exact match) + ``` + + ### Example 3: Filtering descendants with ListenDepth + ```lua + -- Register with depth limit (ancestor notifications deeper than this are skipped): + registry:RegisterListener("ValueChanged", {"Players"}, callback2, { ListenDepth = 0 }) + registry:RegisterListener("ValueChanged", {"Players", "Player1"}, callback3) + registry:RegisterListener("ValueChanged", {"Players", "Player1", "Health"}, callback4) + + -- FireListenersExact at {"Players"} as ancestor notification (OriginPath = {"Players", "Player1", "Health"}): + -- relativeDepth = #OriginPath - #listenerPath = 3 - 1 = 2 + -- callback2: depth=0, style="<=": 2 <= 0 → false → SKIPPED + + -- FireListenersExact at {"Players", "Player1", "Health"} as direct change: + -- relativeDepth = 0 (direct change) + -- callback4: depth=nil → always fires ✓ + ``` + + ### Example 4: Once option + ```lua + -- Register a listener that fires exactly once then disconnects: + local conn = registry:RegisterListener("ValueChanged", {"player", "health"}, function(newValue) + print("Fired once:", newValue) + end, { Once = true }) + + -- First fire: callback runs, conn.Connected becomes false, listener is removed. + -- Subsequent fires: listener is gone, callback never runs again. + -- conn.Connected == false after the first fire. + ``` + + ### Example 5: Listener Below Change Path (No Notification) + ```lua + -- Register: + registry:RegisterListener("ValueChanged", {"Players", "Player1", "Health", "MaxHP"}, callback5) + + -- Fire: FireListenersExact("ValueChanged", {"Players", "Player1", "Health"}, eventData) + + -- callback5 does NOT fire because: + -- - The change is at an ancestor path + -- - Listeners only fire for self or ancestor changes, not parent changes + -- - The tree traversal stops at {"Players", "Player1", "Health"} + ``` + + ### Example 6: Performance Comparison + ```lua + -- Old array-based approach: + -- 1000 total listeners registered at various paths + -- Change at {"Players", "Player1", "Health"} + -- Must iterate ALL 1000 listeners and check path relation for each + -- = ~1000 operations + + -- New tree-based approach: + -- Same 1000 listeners + -- Change at {"Players", "Player1", "Health"} + -- Walk tree: [ROOT] → "Players" → "Player1" → "Health" + -- Only check listeners at these 4 nodes (maybe 10 listeners total) + -- = ~14 operations + + -- Speed improvement: 70x faster! + ``` + + ### Example 7: Wildcard Listeners + ```lua + -- Register a wildcard listener that matches any key at that segment: + registry:RegisterListener("ValueChanged", {"Players", "*", "Health"}, function(newValue, oldValue, metadata) + print(metadata.WildcardMatches[1], "health changed to", newValue) + end) + + -- Fire: FireListenersExact("ValueChanged", {"Players", "p123", "Health"}, eventData) + -- The "*" branch matches "p123", so the listener fires with + -- metadata.WildcardMatches == {"p123"}. + + -- A literal listener registered at {"Players", "p123", "Health"} and the + -- wildcard listener above are different tree nodes and BOTH fire for the + -- same change. + + -- Multiple "*" segments accumulate matches left-to-right, e.g. a listener + -- at {"Players", "*", "Inventory", "*"} fired at + -- {"Players", "p123", "Inventory", "item7"} gets + -- metadata.WildcardMatches == {"p123", "item7"}. + + -- Note: a listener registered with `Once = true` at a wildcard path fires + -- once TOTAL across all matching keys, not once per key, since it lives on + -- a single tree node. + ``` +]=] + +const PathHelpers = require("./PathHelpers") +const ArrayDiffModule = require("./ArrayDiff") +const IsDeferred = require("./IsDeferred") + +--// Types //-- +-- type Path = PathHelpers.Path +type PathArray = PathHelpers.PathArray +type MoveMetadata = ArrayDiffModule.MoveMetadata + +export type EventType = + "ValueChanged" + | "KeyAdded" + | "KeyRemoved" + | "KeyChanged" + | "ArrayInserted" + | "ArrayRemoved" + | "ArraySet" + +-- Metadata structure from ChangeDetector +export type ChangeMetadata = { + Diff: any?, -- DiffNode from ChangeDetector (present for direct changes, nil for ancestor) + OriginPath: PathArray, + OriginDiff: any, -- The actual diff at the origin + -- The literal keys matched by each "*" segment of the listener's registered + -- path, in left-to-right order. nil if the listener path had no wildcards. + WildcardMatches: { any }?, + -- Set on the ArrayRemoved/ArrayInserted pair from a non-batched + -- ArraySwapRemove that constitutes a move. nil otherwise. + Move: MoveMetadata?, +} + +export type EventData = { + NewValue: any?, + OldValue: any?, + Key: any?, + Index: number?, + Metadata: ChangeMetadata?, +} + +export type ListenerOptions = { + --- Maximum depth of descendant changes that will trigger this listener. + --- nil = fire for any depth (default). 0 = fire only for direct changes at the + --- registered path. 1 = also fire for changes 1 level below, etc. + ListenDepth: number?, + --- How the depth is compared against ListenDepth. + --- "<=" (default): fire for changes AT OR WITHIN the given depth. + --- "==": fire ONLY for changes at EXACTLY the given depth. + --- Ignored when ListenDepth is nil. + ListenDepthStyle: ("<=" | "==")?, + --- When true, the listener automatically disconnects after firing once. + Once: boolean?, +} + +export type Connection = { + Disconnect: (Connection) -> (), + Connected: boolean, +} + +-- How listener callbacks are scheduled when fired: +-- "immediate": run now via the free-thread pool (task.spawn), like an RBXScriptSignal in immediate mode. +-- "deferred": run via task.defer, like an RBXScriptSignal in deferred mode. +-- "bindable": mirrors the engine's actual Enum.SignalBehavior (resolved once, at construction time). +-- "coalesced": like "deferred", but repeated fires of the SAME listener before the deferred flush runs +-- collapse into a single call carrying the latest event data, instead of one call per fire. +export type FireMode = "immediate" | "deferred" | "bindable" | "coalesced" + +export type ListenerRegistryConfig = { + DebugMode: boolean?, + FireMode: FireMode, +} + +type Listener = { + Callback: (...any) -> (), + Depth: number?, + DepthStyle: "<=" | "==", + Once: boolean, + PathLength: number, + Connection: Connection, + -- "coalesced" fire-mode pending state; see `flushCoalescedListener`. + _coalescePending: boolean?, + _coalesceEventData: EventData?, +} + +-- Tree node structure for efficient path-based lookups. A single tree serves +-- every event type: each node stores its listeners bucketed by EventType +-- alongside Count (listeners AT this exact node, all event types) and Subtree +-- (listeners at or below this node), so one walk both fires listeners and +-- answers "is anyone watching here?" (CoversChangesAt) with no parallel +-- structure to keep in sync. +type ListenerNode = { + -- Listeners registered at this exact path, bucketed by event type. A + -- bucket is deleted (set to nil) as soon as it goes empty. + Listeners: { [EventType]: { Listener } }, + -- Total listeners (all event types) registered at this exact node. + Count: number, + -- Total listeners at or below this node (Count + every descendant's Subtree). + Subtree: number, + -- Child nodes keyed by path segment. + Children: { [any]: ListenerNode }, + -- Back-pointer and this node's key within Parent.Children, used to prune + -- now-empty nodes back toward the root on disconnect. nil for the root. + Parent: ListenerNode?, + Key: any?, +} + +export type ListenerRegistry = { + RegisterListener: ( + self: ListenerRegistry, + eventType: EventType, + path: PathArray, + callback: (...any) -> (), + options: ListenerOptions? + ) -> Connection, + FireListenersExact: (self: ListenerRegistry, eventType: EventType, path: PathArray, eventData: EventData) -> (), + -- Conservative O(depth) check: true if a change at `path` could reach some + -- registered listener — one AT path, an ANCESTOR of it (ancestor + -- ValueChanged/KeyChanged delivery), or a DESCENDANT of it (a deep write + -- restructures the subtree a descendant listener watches). Never under- + -- reports, so callers can safely skip diff work whenever this is false. + CoversChangesAt: (self: ListenerRegistry, path: PathArray) -> boolean, + Destroy: (self: ListenerRegistry) -> (), + + _root: ListenerNode, + _debugMode: boolean, + -- Resolved fire mode; "bindable" is collapsed into "immediate"/"deferred" at construction. + _fireMode: "immediate" | "deferred" | "coalesced", + _destroyed: boolean, + _hasWildcards: boolean, +} + +-------------------------------------------------------------------------------- +--// Module //-- +-------------------------------------------------------------------------------- + +const ListenerRegistry = {} +const ListenerRegistry_MT = { __index = ListenerRegistry } + +-- The currently idle thread to run the next listener callback on. +local freeRunnerThread: thread? = nil + +const function executeListenerCallback( + callback: (...any) -> (), + eventType: EventType, + eventData: EventData, + debugMode: boolean +) + local success, err + local metadata = eventData.Metadata + if eventType == "KeyAdded" then + success, err = pcall(callback, eventData.Key, eventData.NewValue, metadata) + elseif eventType == "KeyRemoved" then + success, err = pcall(callback, eventData.Key, eventData.OldValue, metadata) + elseif eventType == "KeyChanged" then + success, err = pcall(callback, eventData.Key, eventData.NewValue, eventData.OldValue, metadata) + elseif eventType == "ValueChanged" then + success, err = pcall(callback, eventData.NewValue, eventData.OldValue, metadata) + elseif eventType == "ArrayInserted" then + success, err = pcall(callback, eventData.Index, eventData.NewValue, metadata) + elseif eventType == "ArrayRemoved" then + success, err = pcall(callback, eventData.Index, eventData.OldValue, metadata) + elseif eventType == "ArraySet" then + success, err = pcall(callback, eventData.Index, eventData.NewValue, eventData.OldValue, metadata) + end + + if not success and debugMode then + warn(`ListenerRegistry: Error in {eventType} callback: {err}`) + end +end + +const function acquireRunnerThreadAndCall( + callback: (...any) -> (), + eventType: EventType, + eventData: EventData, + debugMode: boolean +) + local acquiredRunnerThread = freeRunnerThread + freeRunnerThread = nil + executeListenerCallback(callback, eventType, eventData, debugMode) + -- Callback finished. This runner thread can be reused. + freeRunnerThread = acquiredRunnerThread +end + +-- Argless on purpose: the runner thread is primed with an empty resume so it +-- parks at coroutine.yield() before receiving any payload. Taking the first +-- payload as function parameters instead would pin that payload (callback + +-- eventData, including its Metadata.Snapshot.RootTable reference) on the +-- thread's stack for the thread's entire lifetime. +const function runListenerCallbackInFreeThread() + while true do + acquireRunnerThreadAndCall(coroutine.yield()) + end +end + +-- "coalesced" fire-mode flush: runs once via task.defer per pending listener, +-- regardless of how many times that listener was "fired" before this ran. +-- Re-checks Connected at flush time (unlike the "deferred" path) since a +-- coalesced flush can be scheduled well after the listener disconnects. +const function flushCoalescedListener(listener: Listener, eventType: EventType, debugMode: boolean) + listener._coalescePending = false + local eventData = listener._coalesceEventData :: EventData + listener._coalesceEventData = nil + if listener.Connection.Connected then + executeListenerCallback(listener.Callback, eventType, eventData, debugMode) + end +end + +-- Helper to create a new tree node +const function createNode(parent: ListenerNode?, key: any?): ListenerNode + return { + Listeners = {}, + Count = 0, + Subtree = 0, + Children = {}, + Parent = parent, + Key = key, + } +end + +-- Helper to get or create a node at a path. Bumps Subtree by 1 at every node +-- visited (root included) since the caller is about to register exactly one +-- listener at the returned node. +const function getOrCreateNode(root: ListenerNode, path: PathArray): ListenerNode + local current = root + current.Subtree += 1 + for _, segment in path do + local child = current.Children[segment] + if not child then + child = createNode(current, segment) + current.Children[segment] = child + end + child.Subtree += 1 + current = child + end + return current +end + +-- Reserved path segment used to register a listener that matches any literal +-- key at that position (e.g. {"Players", "*", "Health"}). +const WILDCARD = "*" + +type MatchedNode = { + node: ListenerNode, + -- Literal keys matched by each "*" segment, in left-to-right order. nil if + -- no wildcard segments were traversed to reach this node. + matches: { any }?, + -- The registered path used to reach this node (with "*" where a wildcard + -- branch was taken). Used for Once-listener cleanup. + nodePath: PathArray, +} + +-- Recursively walks `path`, following both literal-segment children and "*" +-- (wildcard) children, collecting every listener node that matches. +const function collectMatchingNodes( + node: ListenerNode, + path: PathArray, + index: number, + matches: { any }, + nodePath: PathArray, + results: { MatchedNode } +) + if index > #path then + table.insert(results, { + node = node, + matches = if #matches > 0 then matches else nil, + nodePath = nodePath, + }) + return + end + + local segment = path[index] + + local literalChild = node.Children[segment] + if literalChild then + local childPath = PathHelpers.Append(nodePath, segment) + collectMatchingNodes(literalChild, path, index + 1, matches, childPath, results) + end + + if segment ~= WILDCARD then + local wildcardChild = node.Children[WILDCARD] + if wildcardChild then + local newMatches = PathHelpers.Append(matches, segment) + local childPath = PathHelpers.Append(nodePath, WILDCARD) + collectMatchingNodes(wildcardChild, path, index + 1, newMatches, childPath, results) + end + end +end + +-- Decrements `node`'s Subtree (and every ancestor's, up to and including the +-- root) by `amount`, pruning any non-root node that becomes fully empty +-- (Count == 0 and Subtree == 0) along the way. `node.Count` must already +-- reflect the removal before this runs. Pruning is mandatory (not optional) +-- so a registry with churny listeners never accumulates dead spine nodes. +const function decrementAndPrune(node: ListenerNode, amount: number) + local current: ListenerNode? = node + while current ~= nil do + current.Subtree -= amount + local parent = current.Parent + if parent ~= nil and current.Count == 0 and current.Subtree == 0 then + parent.Children[current.Key] = nil + end + current = parent + end +end + +-- Non-creating node lookup: returns the node at `path` or nil if any segment is absent. +const function getNode(root: ListenerNode, path: PathArray): ListenerNode? + local current = root + for _, segment in path do + current = current.Children[segment] + if not current then + return nil + end + end + return current +end + +-- Returns true if the listener should fire given how many path segments below the +-- listener's registered path the change originated. +-- relativeDepth = 0: change is AT the listener's exact path. +-- relativeDepth > 0: change originated that many levels deeper (ancestor notification). +const function shouldFireListener(listener: Listener, relativeDepth: number): boolean + if listener.Depth == nil then + return true + end + if listener.DepthStyle == "==" then + return relativeDepth == listener.Depth + else -- "<=" + return relativeDepth <= listener.Depth + end +end + +-- Fires all listeners of `eventType` on a single already-located node and +-- handles Once cleanup. Extracted so both the no-wildcard fast path and the +-- wildcard path share one implementation of the inner fire loop. +const function fireListenersOnNode( + node: ListenerNode, + eventType: EventType, + fireEventData: EventData, + baseRelativeDepth: number, + fireMode: "immediate" | "deferred" | "coalesced", + debugMode: boolean +) + local listeners = node.Listeners[eventType] + if listeners == nil then + return + end + local hasOnceFired = false + for _, listener in listeners do + if not listener.Connection.Connected then + continue + end + if not shouldFireListener(listener, baseRelativeDepth) then + continue + end + if listener.Once then + listener.Connection.Connected = false + hasOnceFired = true + end + if fireMode == "deferred" then + task.defer(executeListenerCallback, listener.Callback, eventType, fireEventData, debugMode) + elseif fireMode == "coalesced" then + if listener._coalescePending then + listener._coalesceEventData = fireEventData + else + listener._coalescePending = true + listener._coalesceEventData = fireEventData + task.defer(flushCoalescedListener, listener, eventType, debugMode) + end + else + if not freeRunnerThread then + freeRunnerThread = coroutine.create(runListenerCallbackInFreeThread) + coroutine.resume(freeRunnerThread :: thread) + end + task.spawn(freeRunnerThread :: thread, listener.Callback, eventType, fireEventData, debugMode) + end + end + if hasOnceFired then + local removed = 0 + for i = #listeners, 1, -1 do + if listeners[i].Once and not listeners[i].Connection.Connected then + table.remove(listeners, i) + removed += 1 + end + end + if next(listeners) == nil then + node.Listeners[eventType] = nil + end + node.Count -= removed + decrementAndPrune(node, removed) + end +end + +const function resolveFireMode(configuredMode: FireMode): "immediate" | "deferred" | "coalesced" + assert( + type(configuredMode) == "string", + `ListenerRegistry: FireMode must be a string, got {typeof(configuredMode)}` + ) + configuredMode = configuredMode:lower() + if configuredMode == "bindable" then + return if IsDeferred() then "deferred" else "immediate" + elseif configuredMode == "immediate" or configuredMode == "deferred" or configuredMode == "coalesced" then + return configuredMode + end + error( + `ListenerRegistry: Invalid FireMode "{configuredMode}". Must be "immediate", "deferred", "bindable", or "coalesced".` + ) +end + +function ListenerRegistry.new(config: ListenerRegistryConfig?): ListenerRegistry + local self: ListenerRegistry = setmetatable({}, ListenerRegistry_MT) :: ListenerRegistry + const resolvedConfig: ListenerRegistryConfig = config or {} :: any + local debugMode = if resolvedConfig.DebugMode ~= nil then resolvedConfig.DebugMode else false + + self._root = createNode(nil, nil) + self._debugMode = debugMode + self._fireMode = resolveFireMode(resolvedConfig.FireMode or "bindable") + self._destroyed = false + self._hasWildcards = false + + return self +end + +function ListenerRegistry:RegisterListener( + eventType: EventType, + path: PathArray, + callback: (...any) -> (), + options: ListenerOptions? +): Connection + -- A destroyed registry must stay empty: registrations would repopulate trees + -- that will never be cleaned again. Hand back an inert, dead connection. + if self._destroyed then + return { + Connected = false, + Disconnect = function() end, + } :: Connection + end + + local depth: number? = if options then options.ListenDepth else nil + local depthStyle: "<=" | "==" = if options and options.ListenDepthStyle then options.ListenDepthStyle else "<=" + local once: boolean = if options and options.Once == true then true else false + + -- One-way latch: once any wildcard listener is registered the fast path in + -- FireListenersExact is permanently bypassed for this registry. + if not self._hasWildcards then + for _, segment in path do + if segment == WILDCARD then + self._hasWildcards = true + break + end + end + end + + -- Navigate to the node for this path, creating nodes as needed (bumps + -- Subtree along the way for the listener being added below). + local node = getOrCreateNode(self._root, path) + + -- Create listener first + local listener: Listener = { + Callback = callback, + Depth = depth, + DepthStyle = depthStyle, + Once = once, + PathLength = #path, + Connection = nil :: any, -- Will be set below + } + + -- Create connection with access to registry and listener + local connection: Connection = { + Connected = true, + Disconnect = function(conn: Connection) + if not conn.Connected then + return + end + conn.Connected = false + -- Remove listener from its event-type bucket on the node + local bucket = node.Listeners[eventType] + if bucket then + for i, l in bucket do + if l.Connection == conn then + table.remove(bucket, i) + break + end + end + if next(bucket) == nil then + node.Listeners[eventType] = nil + end + end + -- Decrement coverage counts and prune now-empty nodes + node.Count -= 1 + decrementAndPrune(node, 1) + end, + } :: Connection + + -- Set connection in listener + listener.Connection = connection + + -- Store listener in the node's event-type bucket + local bucket = node.Listeners[eventType] + if bucket == nil then + bucket = {} + node.Listeners[eventType] = bucket + end + table.insert(bucket, listener) + node.Count += 1 + + return connection +end + +--[=[ + Fires listeners ONLY at the exact path provided, without any ancestor/descendant matching. + + This is used for ChangeDetector callbacks, which already handle the full notification chain + including ancestor propagation. Using this method prevents duplicate notifications. + + Depth filtering: ancestor notifications (metadata.Diff == nil) compute relative depth from + metadata.OriginPath against the listener's registered path length. Direct change + notifications (Diff present) always use depth 0. + + Once listeners are marked as disconnected before their callback fires (re-entrancy safe), + then physically removed from the node in a backward sweep after the fire loop. + + @param eventType The type of event to fire + @param path The exact path where listeners should be notified + @param eventData The event data to pass to callbacks +]=] +function ListenerRegistry.FireListenersExact( + self: ListenerRegistry, + eventType: EventType, + path: PathArray, + eventData: EventData +) + local root = self._root + local debugMode = self._debugMode + local fireMode = self._fireMode + + -- For ancestor notifications (Diff == nil), compute relative depth from OriginPath. + -- Direct change notifications always have relativeDepth = 0 (change is at the listener's path). + local isAncestorNotification = eventData.Metadata and eventData.Metadata.Diff == nil + local baseRelativeDepth: number = 0 + if isAncestorNotification and eventData.Metadata then + baseRelativeDepth = #eventData.Metadata.OriginPath - #path + if baseRelativeDepth < 0 then + baseRelativeDepth = 0 + end + end + + if not self._hasWildcards then + -- Fast path: no wildcard listeners have ever been registered; walk directly to + -- the exact node without the O(depth²) collectMatchingNodes traversal. + local node = getNode(root, path) + if node == nil then + return + end + fireListenersOnNode(node, eventType, eventData, baseRelativeDepth, fireMode, debugMode) + return + end + + -- Wildcard path: collect all matching nodes (literal + wildcard branches). + local results: { MatchedNode } = {} + collectMatchingNodes(root, path, 1, {}, {}, results) + if #results == 0 then + return + end + + for _, result in results do + -- Listeners reached via a wildcard branch get the matched keys attached + -- to a per-result copy of the metadata; literal-only matches reuse eventData as-is. + local fireEventData = eventData + if result.matches then + local metadata = eventData.Metadata + local newMetadata = if metadata then table.clone(metadata) else {} :: ChangeMetadata + newMetadata.WildcardMatches = result.matches + fireEventData = table.clone(eventData) + fireEventData.Metadata = newMetadata + end + fireListenersOnNode(result.node, eventType, fireEventData, baseRelativeDepth, fireMode, debugMode) + end +end + +--[=[ + Conservative O(depth) coverage check: true if a change at `path` could + reach some registered listener — one AT path, an ANCESTOR of it (ancestor + ValueChanged/KeyChanged delivery), or a DESCENDANT of it (a deep write + restructures the subtree a descendant listener watches). Never under- + reports (a depth-limited listener that wouldn't actually fire still counts + as coverage), so callers can safely skip diff work whenever this is false. + + @param path The path a write is about to land on. + @return true if some listener could be affected by a change at `path`. +]=] +function ListenerRegistry:CoversChangesAt(path: PathArray): boolean + if self._hasWildcards then + return true -- a wildcard could match anywhere; no cheap way to rule it out + end + local node = self._root + if node.Count > 0 then + return true -- a root listener is an ancestor of every path + end + for _, segment in path do + local child = node.Children[segment] + if child == nil then + return false -- no listener at this prefix, nor anywhere below it + end + node = child + if node.Count > 0 then + return true -- listener at an ancestor of `path` (or at `path` itself) + end + end + return node.Subtree > 0 -- listener at `path` or somewhere in its subtree +end + +function ListenerRegistry.Destroy(self: ListenerRegistry) + if self._destroyed then + return + end + self._destroyed = true + + -- Recursively disconnect all listeners in the tree. Mark connections dead + -- directly instead of calling Disconnect(): Disconnect table.remove's from + -- the array being iterated (skipping every other listener) and runs + -- redundant cleanupNode passes on a tree that is being cleared wholesale. + local function destroyNode(node: ListenerNode) + for _, bucket in node.Listeners do + for _, listener in bucket do + listener.Connection.Connected = false + end + end + table.clear(node.Listeners) + node.Count = 0 + node.Subtree = 0 + + for _, child in node.Children do + destroyNode(child) + end + table.clear(node.Children) + end + + destroyNode(self._root) +end + +return ListenerRegistry diff --git a/lib/tablemanager2/src/Mutator.luau b/lib/tablemanager2/src/Mutator.luau new file mode 100644 index 00000000..a9f343ed --- /dev/null +++ b/lib/tablemanager2/src/Mutator.luau @@ -0,0 +1,571 @@ +--!strict +--[=[ + @ignore + @class Mutator + + The write core for TableManager. Owns path resolution, duplicate-reference + checks, schema validation, the proxy-free write engine (`applyWrite`), proxy + reparenting helpers, and the `pruneDetachedValue`/`shiftArrayKeys` post-write + bookkeeping. +]=] + +--// Imports //-- +const PathHelpers = require("./PathHelpers") +const BatchUtilsModule = require("./BatchUtils") +const ProxyManagerModule = require("./ProxyManager") +const SchemaNavigatorModule = require("./SchemaNavigator") +const LinkGroupModule = require("./LinkGroup") +const EmitterModule = require("./Emitter") +const IgnoreTrieModule = require("./IgnoreTrie") +const CoverageModule = require("./Coverage") +const CoalescedFlushModule = require("./CoalescedFlush") +const T = require("../T") +const TMTypes = require("./TMTypes") + +--// Localize batch utils to avoid function call overhead //-- +const markBatchBranchDirty = BatchUtilsModule.MarkBatchBranchDirty +const ensureBatchPathTracking = BatchUtilsModule.EnsureBatchPathTracking + +--// Types //-- +type PathArray = PathHelpers.PathArray +type Path = PathHelpers.Path +type ValueAtPath = PathHelpers.ValueAtPath +type TM_Internal = TMTypes.TM_Internal +type TableManagerLike = TMTypes.TableManagerLike +type ProxyManager = ProxyManagerModule.ProxyManager +type Proxy = TMTypes.Proxy + +const Mutator = {} + +-- --------------------------------------------------------------------------- +-- Path resolution helpers (exported — used by public Set / array methods) +-- --------------------------------------------------------------------------- + +function Mutator.resolvePathFromPathOrProxy(self: TM_Internal, pathOrProxy: Path | Proxy): PathArray + const proxyManager = self._proxyManager + if proxyManager == nil then + if ProxyManagerModule.IsProxyValue(pathOrProxy) then + error("GetProxy requires proxies (Config.EnableProxies = false for this TableManager)") + end + return PathHelpers.ParsePath(pathOrProxy :: Path) + end + if proxyManager:IsProxy(pathOrProxy) then + const proxy = pathOrProxy :: Proxy + const potentialPath = proxyManager:GetPath(proxy) + assert(potentialPath, "Proxy does not have a path") + return potentialPath + end + return PathHelpers.ParsePath(pathOrProxy :: Path) +end + +function Mutator.resolveArrayForWrite(self: TM_Internal, pathOrProxy: Path | Proxy): (PathArray, { any }) + const parsedPath = Mutator.resolvePathFromPathOrProxy(self, pathOrProxy) + const array = self:Get(parsedPath) + assert(type(array) == "table", "Target is not a table") + return parsedPath, array :: { any } +end + +function Mutator.getParentOriginalAtPath(self: TM_Internal, parentPath: PathArray, opName: string): {} + local parentOriginal = if #parentPath == 0 then self.Raw else self:Get(parentPath) + if type(parentOriginal) ~= "table" then + error(`{opName} destination parent must be a table`) + end + return parentOriginal +end + +-- --------------------------------------------------------------------------- +-- Deep clone (used by CopyTo) +-- --------------------------------------------------------------------------- + +function Mutator.deepCloneValue(value: any, seen: { [any]: any }?): any + if type(value) ~= "table" then + return value + end + const memo = seen or {} + if memo[value] ~= nil then + return memo[value] + end + const clone = {} + memo[value] = clone + for k, v in value do + clone[Mutator.deepCloneValue(k, memo)] = Mutator.deepCloneValue(v, memo) + end + return clone +end + +-- --------------------------------------------------------------------------- +-- Proxy reparenting helpers (used by MoveTo / CopyTo / Swap) +-- --------------------------------------------------------------------------- + +export type ReparentRollback = { + Proxy: Proxy, + Parent: any?, + Key: any?, +} + +--[[ + Reparents the proxy backing `value` to `newParent`/`newKey`, returning a + rollback handle. No-op when proxies are disabled, `value` is not a table, + or it has no live proxy. +]] +function Mutator.reparentWithRollback( + proxyManager: ProxyManager?, + value: any, + newParent: any, + newKey: any +): ReparentRollback? + if proxyManager == nil or type(value) ~= "table" then + return nil + end + const proxy = proxyManager:GetProxyFromOriginal(value) + if proxy == nil then + return nil + end + local oldParent: any? = nil + local oldKey: any? = nil + const existingMeta = proxyManager:GetMetadata(proxy) + if existingMeta ~= nil then + oldParent = existingMeta.Parent + oldKey = existingMeta.Key + end + proxyManager:ReparentProxy(proxy, newParent, newKey) + return { + Proxy = proxy, + Parent = oldParent, + Key = oldKey, + } +end + +function Mutator.restoreReparent(proxyManager: ProxyManager?, rollback: ReparentRollback?) + if proxyManager == nil or rollback == nil then + return + end + proxyManager:ReparentProxy(rollback.Proxy, rollback.Parent, rollback.Key) +end + +-- --------------------------------------------------------------------------- +-- Array bookkeeping helpers (exported — used by _NotifyApplied and array methods) +-- --------------------------------------------------------------------------- + +--[[ + Shifts Key metadata of proxies in `array` after an insert (+1) or remove (-1). + No-op when proxies are disabled or `array` is not a table. +]] +function Mutator.shiftArrayKeys(proxyManager: ProxyManager?, array: any, fromIndex: number, delta: number) + if proxyManager == nil or type(array) ~= "table" then + return + end + proxyManager:ShiftKeys(array, fromIndex, delta) +end + +--[[ + Evicts proxy bookkeeping for a value detached by a write. During a batch, + the prune is deferred until Resume (so a value re-homed in the same window + is spared by PruneOriginal's liveness guard). +]] +function Mutator.pruneDetachedValue(self: TableManagerLike, oldValue: any, newValue: any) + if type(oldValue) ~= "table" or oldValue == newValue then + return + end + const proxyManager = self._proxyManager + if proxyManager == nil then + return + end + const batch = self._batch + if self._batchDepth > 0 and batch then + table.insert(batch.PendingPrunes, oldValue) + else + proxyManager:PruneOriginal(oldValue) + end +end + +-- --------------------------------------------------------------------------- +-- Batch tracking helper for array mutations +-- --------------------------------------------------------------------------- + +--[[ + If in a batch, marks `parsedPath`'s array as tracked and its branch dirty. + Returns `true` when in a batch (caller should skip immediate event firing). +]] +function Mutator.trackArrayMutation(self: TableManagerLike, parsedPath: PathArray): boolean + if self._batchDepth > 0 then + const batch = self._batch + if batch then + ensureBatchPathTracking(batch, parsedPath) + end + markBatchBranchDirty(batch, parsedPath) + return true + end + return false +end + +-- --------------------------------------------------------------------------- +-- Write validation (module-internal, used only by applyWrite) +-- --------------------------------------------------------------------------- + +local function validateWrite(self: TM_Internal, path: PathArray, value: ValueAtPath): (boolean, string?) + if not self._schema then + return true :: any + end + const ok, err = SchemaNavigatorModule.Validate(self._schema, path, value) + if ok then + return true :: any + end + const message = err or `Schema validation failed at {PathHelpers.PathToString(path)}` + if self._onValidationFailed then + self._onValidationFailed(path, value, message) + return false, nil + end + return false, message +end + +-- --------------------------------------------------------------------------- +-- Observation gate (module-internal, used only by applyWrite/onArrayAppended) +-- --------------------------------------------------------------------------- + +-- See Coverage.luau: true if a write at `path` could be observed by anything +-- at all. False means the upcoming shadow-seed/diff is provably unobservable +-- and can be skipped. Kept as a local alias so call sites below stay unchanged. +const shouldTrackChangesAt = CoverageModule.IsTrackable + +-- --------------------------------------------------------------------------- +-- Shadow seeding (module-internal, used only by applyWrite/applyRootSet) +-- --------------------------------------------------------------------------- + +-- Seeds the persistent shadow at `path` with `oldValue` (the live value +-- immediately before this write) the FIRST time `path` is found trackable but +-- not yet materialized — e.g. the first write since a listener started +-- covering it. `oldValue == nil` needs no seeding: a missing shadow entry +-- already reads as `nil`, which is the correct baseline when the key never +-- existed. Must be called BEFORE the write so `oldValue`/`self.Raw` +-- still reflect the pre-write state. Callers must check +-- `not Coverage.PassesThroughOpaqueAncestor(...)` first — Materialize cannot +-- safely seed through an opaque ancestor (see Coverage.luau). +local function ensureShadowSeeded(self: TM_Internal, path: PathArray, oldValue: any, ctx: any) + if oldValue == nil then + return + end + if self._shadow:Get(path) ~= nil then + return + end + self._shadow:Materialize(path, self.Raw, ctx) +end + +-- --------------------------------------------------------------------------- +-- Array-append notification (module-internal, used only by applyWrite) +-- --------------------------------------------------------------------------- + +local function onArrayAppended(self: TM_Internal, path: PathArray, index: number, newValue: any) + if self._batchDepth > 0 then + const batch = self._batch + if batch then + ensureBatchPathTracking(batch, path) + end + markBatchBranchDirty(batch, path) + return + end + if not shouldTrackChangesAt(self, path) then + return + end + const insertPath: { any } = PathHelpers.Append(path :: any, index) + const metadata = EmitterModule.makeSyntheticMetadata(self.Raw, insertPath, "added", index, newValue, nil) + EmitterModule.fireArrayOperation(self, "ArrayInserted", path, insertPath, { + Index = index, + NewValue = newValue, + Metadata = metadata, + }) +end + +-- --------------------------------------------------------------------------- +-- Duplicate-reference policy (module-internal) +-- --------------------------------------------------------------------------- + +local function resolveDuplicateReferencePolicy( + self: TM_Internal, + writePath: PathArray, + existingPath: PathArray, + value: any +): boolean + if self._duplicateReferenceMode == "allow" then + return true + end + const writePathStr = PathHelpers.PathToString(writePath) + const existingPathStr = PathHelpers.PathToString(existingPath) + const duplicateMessage = + `Duplicate table reference detected: existing at {existingPathStr}, attempted write to {writePathStr}` + if self._duplicateReferenceMode == "warn" then + warn(duplicateMessage) + return true + end + if self._duplicateReferenceMode == "move" then + if self._isDuplicateMoveInProgress then + return true + end + self._isDuplicateMoveInProgress = true + local ok, moveErr = pcall(function() + self:MoveTo(existingPath, writePath) + end) + self._isDuplicateMoveInProgress = false + if not ok then + error(`Failed to move duplicate table reference: {tostring(moveErr)}`, 2) + end + if self:Get(writePath) ~= value then + error( + `DuplicateReferenceMode 'move' is not fully implemented yet. Expected MoveTo to place the value at {writePathStr}`, + 2 + ) + end + return false + end + if self._duplicateReferenceMode == "copy" then + error("DuplicateReferenceMode 'copy' is not implemented yet", 2) + end + error(duplicateMessage, 2) +end + +local function checkDuplicateTableWrite(self: TM_Internal, writePath: PathArray, value: any): boolean + const proxyManager = self._proxyManager + if proxyManager == nil then + return true + end + const existingProxy = proxyManager:GetProxyFromOriginal(value) + if existingProxy == nil then + return true + end + const existingPath = proxyManager:GetPath(existingProxy) + if existingPath == nil or PathHelpers.ArePathsEqual(existingPath, writePath) then + return true + end + const isOrphan = self:Get(existingPath, true) ~= value + if isOrphan then + return true + end + return resolveDuplicateReferencePolicy(self, writePath, existingPath, value) +end + +-- --------------------------------------------------------------------------- +-- Write core (exported) +-- --------------------------------------------------------------------------- + +--[[ + The single proxy-free write core. `parsedPath` is the full path (including + the final key); `value` must already be unwrapped + (`ProxyManagerModule.Unwrap`). Order: duplicate-check → validation → + array-append fast path → batched dirty-marking (no flush) OR + shadow-seed/write/flush (immediate). +]] +function Mutator.applyWrite(self: TM_Internal, parsedPath: PathArray, newValue: any) + debug.profilebegin("applyWrite") + const parentPath, key = PathHelpers.GetPathParentAndKey(parsedPath) + + -- Navigate to the parent table of the write. + local current: any = self.Raw + for _, segment in parentPath do + if type(current) ~= "table" then + error(`Path segment {segment} is not a table`) + end + current = current[segment] + end + if type(current) ~= "table" then + error(`Path segment {key} is not a table`) + end + const parentTable: { [any]: any } = current + + -- Check if the value is a table and if it is already managed by this TableManager. + if type(newValue) == "table" and self._proxyManager ~= nil then + const shouldProceed = checkDuplicateTableWrite(self, parsedPath, newValue) + if not shouldProceed then + debug.profileend() + return + end + end + + -- Verify the write against the schema, if one is configured. + const ok, err = validateWrite(self, parsedPath, newValue) + if not ok then + if err then + error(err, 2) + end + debug.profileend() + return + end + + -- Ignored paths skip the diff/snapshot/event machinery entirely (the + -- expensive part) — the value below is still written normally, so + -- Get/Raw reflect it immediately; only change detection is short-circuited. + const ignored = IgnoreTrieModule.IsPathIgnored(self._ignoreTrie, parsedPath) + + -- Array-append fast path (key == length + 1): apply immediately and report. + -- Only classify the parent table when the key is numeric; classification is O(n) + -- and its result is never used for string/non-numeric keys. + local isArray, arrayLength = false, 0 + if type(key) == "number" then + isArray, arrayLength = ProxyManagerModule.ClassifyTable(parentTable) + end + if isArray and key == arrayLength + 1 then + parentTable[key] = newValue + if not ignored then + onArrayAppended(self, parentPath, key, newValue) + end + if self._autoLink and not self._suppressLinkFanOut then + LinkGroupModule.CheckAutoLink(self, parsedPath, newValue) + end + debug.profileend() + return + end + + if self._batchDepth > 0 then + const batch = self._batch + if isArray and batch then + ensureBatchPathTracking(batch, parentPath) + end + markBatchBranchDirty(batch, parentPath) + + -- Batched writes don't flush per-op — Resume's two-phase flush owns + -- firing for everything marked dirty above. + const oldValue = parentTable[key] + parentTable[key] = newValue + if not self._suppressLinkFanOut then + LinkGroupModule.CheckDivergence(self, parsedPath) + if self._autoLink then + LinkGroupModule.CheckAutoLink(self, parsedPath, newValue) + end + end + Mutator.pruneDetachedValue(self, oldValue, newValue) + debug.profileend() + return + end + + const oldValue = parentTable[key] + const trackable = not ignored and shouldTrackChangesAt(self, parsedPath) + const ctx = if trackable then self._changeDetector:GetOpaqueCtx() else nil + -- An opaque ancestor can't safely host a persistent shadow entry for a + -- descendant path (see Coverage.PassesThroughOpaqueAncestor): fall back to + -- a one-off diff using `oldValue` already read fresh above, instead of + -- going through the shadow at all. + const opaqueBypass = trackable and CoverageModule.PassesThroughOpaqueAncestor(self, parsedPath, ctx) + if trackable and not opaqueBypass then + ensureShadowSeeded(self, parsedPath, oldValue, ctx) + end + parentTable[key] = newValue + if not self._suppressLinkFanOut then + LinkGroupModule.CheckDivergence(self, parsedPath) + if self._autoLink then + LinkGroupModule.CheckAutoLink(self, parsedPath, newValue) + end + end + Mutator.pruneDetachedValue(self, oldValue, newValue) + if opaqueBypass then + self._changeDetector:CheckForChangesBetween(oldValue, newValue, parsedPath, self.Raw) + elseif trackable then + CoalescedFlushModule.Request(self, parsedPath) + end + debug.profileend() +end + +--[[ + Replace the entire root table of the manager (the `Set({}, newRoot)` case). + The root has no parent slot, so instead of `parentTable[key] = value` this + swaps the manager's own root references and retargets the proxy graph, then + fires the old→new diff via `CheckForChangesBetween`. `newRoot` must already be + unwrapped (`ProxyManagerModule.Unwrap`). Order mirrors `applyWrite`: + dup-check → validation → apply → link reconcile → diff. +]] +function Mutator.applyRootSet(self: TM_Internal, newRoot: any) + if type(newRoot) ~= "table" then + error("Cannot set the root to a non-table value; the root must remain a table") + end + + const oldRoot = self.Raw + if newRoot == oldRoot then + return + end + + if self._batchDepth > 0 then + error("Cannot replace the root table inside a batch") + end + + const rootPath = {} :: PathArray + + -- Duplicate-reference guard: an already-managed table cannot silently become + -- the root under the configured DuplicateReferenceMode. + if type(newRoot) == "table" and self._proxyManager ~= nil then + const shouldProceed = checkDuplicateTableWrite(self, rootPath, newRoot) + if not shouldProceed then + return + end + end + + const ok, err = validateWrite(self, rootPath, newRoot) + if not ok then + if err then + error(err, 2) + end + return + end + + -- Seed the shadow from `oldRoot` BEFORE swapping `self.Raw` — + -- `ensureShadowSeeded` reads "current live" off `self.Raw`, + -- which is still `oldRoot` at this point. + const trackable = shouldTrackChangesAt(self, rootPath) + if trackable then + -- rootPath is always {}, so this never builds an intermediate spine + -- (no opaque-ancestor risk; the root itself can never be opaque). + ensureShadowSeeded(self, rootPath, oldRoot, self._changeDetector:GetOpaqueCtx()) + end + + -- Swap identity. `oldRoot` keeps its contents (we only repoint references and + -- never mutate the old object), so the diff below sees the true prior state. + self.Raw = newRoot + self.Raw = newRoot + if self._proxyManager ~= nil then + self._proxyManager:RetargetRoot(oldRoot, newRoot) + end + + -- Reconcile link groups: anchors that no longer resolve to their shared + -- object are severed (the `{}` case of the ancestor-replacement handling). + if not self._suppressLinkFanOut then + LinkGroupModule.OnRootReplaced(self) + end + + -- Fire change events for the full root diff (shadow@{} vs the now-live + -- newRoot), then reconcile the shadow to newRoot. + if trackable then + CoalescedFlushModule.Request(self, rootPath) + end +end + +-- --------------------------------------------------------------------------- +-- Array schema validation helper (exported — used by ArrayInsert) +-- --------------------------------------------------------------------------- + +--[[ + Validates a value being inserted into the array at `arrayPath` against the + schema's element check, if one is configured. Fires `onValidationFailed` and + returns `false` (caller should return early) on failure. +]] +function Mutator.validateArrayInsert(self: TM_Internal, arrayPath: PathArray, value: any): boolean + if not self._schema then + return true + end + const arrayCheck = SchemaNavigatorModule.Navigate(self._schema, arrayPath) + if not arrayCheck then + return true + end + const arrayMeta = T.GetMeta(arrayCheck) + if not arrayMeta or arrayMeta.kind ~= "array" then + return true + end + const ok, err = arrayMeta.valueCheck(value) + if ok then + return true + end + const message = err or `Schema validation failed at {PathHelpers.PathToString(arrayPath)}` + if self._onValidationFailed then + self._onValidationFailed(arrayPath, value, message) + return false + end + error(message, 2) +end + +return Mutator diff --git a/lib/tablemanager2/src/OpaqueRegistry.luau b/lib/tablemanager2/src/OpaqueRegistry.luau new file mode 100644 index 00000000..b71e3e0c --- /dev/null +++ b/lib/tablemanager2/src/OpaqueRegistry.luau @@ -0,0 +1,169 @@ +--!strict +--[=[ + @ignore + @class OpaqueRegistry + + Tracks which live table references are "opaque" to the diff engine: their + internal structure is never cloned, frozen, walked, or diffed — only + identity (`==`) is compared. Marking is by VALUE REFERENCE (weak-keyed + registries), never by path, since paths move (array shifts, `MoveTo`, + `Swap`) but identity does not. + + Two wrapper families, each with a per-manager and a global variant: + - `Opaque` / `GlobalOpaque`: the wrapped value itself is an opaque leaf. + - `OpaqueChildren` / `GlobalOpaqueChildren`: the wrapped value stays + transparent (its own add/remove is still tracked), but its direct + table-typed children are treated as opaque leaves. Lets a container be + marked once so later-inserted elements are automatically opaque. + + Wrapper constructors are pure (no registration side effect until a value + is written through `TableManager.Set`/`ArrayInsert`, which calls + `UnwrapAndRegister`). +]=] + +const OpaqueRegistry = {} + +--// Wrapper markers//-- +const OPAQUE = "O" +const GLOBAL_OPAQUE = "GO" +const OPAQUE_CHILDREN = "OC" +const GLOBAL_OPAQUE_CHILDREN = "GOC" + +const OPAQUE_KIND_KEY = "__opaque_kind" +type WrapperKind = "O" | "GO" | "OC" | "GOC" +export type Wrapper = { inner: { [any]: any }, __opaque_kind: WrapperKind } + +--// Module-global registries (shared across every TableManager) //-- +local globalOpaque: { [any]: true } = setmetatable({}, { __mode = "k" }) :: any +local globalOpaqueChildren: { [any]: true } = setmetatable({}, { __mode = "k" }) :: any + +-- Incremented when a value is newly registered into either global registry; +-- never decremented (entries are GC'd via the weak table, but re-checking +-- "is this number still accurate" on every removal would defeat the point of +-- a cheap gate). Worst case the oracle stays "active" slightly longer than +-- strictly necessary after the last global mark is collected. +local globalCount = 0 + +export type Registries = { + Opaque: { [any]: true }, + OpaqueChildren: { [any]: true }, + Count: number, +} + +function OpaqueRegistry.NewRegistries(): Registries + return { + Opaque = setmetatable({}, { __mode = "k" }) :: any, + OpaqueChildren = setmetatable({}, { __mode = "k" }) :: any, + Count = 0, + } +end + +--// Wrapper constructors //-- +local function OpaqueWrapperGenerator(value: any, opaque_kind: WrapperKind): Wrapper + return table.freeze({ inner = value, [OPAQUE_KIND_KEY] = opaque_kind } :: any) :: Wrapper +end + +function OpaqueRegistry.Opaque(value: any): Wrapper + return OpaqueWrapperGenerator(value, OPAQUE) +end + +function OpaqueRegistry.GlobalOpaque(value: any): Wrapper + return OpaqueWrapperGenerator(value, GLOBAL_OPAQUE) +end + +function OpaqueRegistry.OpaqueChildren(value: any): Wrapper + return OpaqueWrapperGenerator(value, OPAQUE_CHILDREN) +end + +function OpaqueRegistry.GlobalOpaqueChildren(value: any): Wrapper + return OpaqueWrapperGenerator(value, GLOBAL_OPAQUE_CHILDREN) +end + +local function wrapperKind(value: any): WrapperKind? + if type(value) ~= "table" then + return nil + end + return (value :: any)[OPAQUE_KIND_KEY] +end + +--[=[ + If `value` is one of the four wrapper kinds, registers its inner value + into the right registry (`registries`, the per-manager set, for the + non-global kinds; the module-global registries otherwise) and returns the + inner value. Otherwise returns `value` unchanged. + + Opacity is meaningless for scalars (there is no internal structure to + avoid cloning/walking) — wrapping a scalar unwraps it but does not + register it, and warns. +]=] +function OpaqueRegistry.UnwrapAndRegister(registries: Registries, value: any): any + local kind = wrapperKind(value) + if kind == nil then + return value + end + + local inner = (value :: Wrapper).inner + if type(inner) ~= "table" then + warn("Opaque/OpaqueChildren wrappers have no effect on non-table values; unwrapping without marking.") + return inner + end + + if kind == OPAQUE then + if registries.Opaque[inner] == nil then + registries.Count += 1 + end + registries.Opaque[inner] = true + elseif kind == OPAQUE_CHILDREN then + if registries.OpaqueChildren[inner] == nil then + registries.Count += 1 + end + registries.OpaqueChildren[inner] = true + elseif kind == GLOBAL_OPAQUE then + if globalOpaque[inner] == nil then + globalCount += 1 + end + globalOpaque[inner] = true + elseif kind == GLOBAL_OPAQUE_CHILDREN then + if globalOpaqueChildren[inner] == nil then + globalCount += 1 + end + globalOpaqueChildren[inner] = true + end + + return inner +end + +-- Exposed so a manager can cheaply re-check "did the global registry become +-- active since I last built my ctx" without taking a dependency on the +-- module-level local. +function OpaqueRegistry.GlobalCount(): number + return globalCount +end + +export type Ctx = { + isOpaque: (ref: any) -> boolean, + hasOpaqueChildren: (ref: any) -> boolean, +} + +--[=[ + Builds the persistent `ctx` oracle closures for a manager's registries. + Cheap to call once and hold onto — the closures simply close over the + (mutable) registry tables, so they stay correct as marks are added later. + Callers should gate USE of the returned ctx behind `registries.Count > 0 + or OpaqueRegistry.GlobalCount() > 0` (see `ChangeDetector`) rather than + rebuilding it, so an unused oracle costs nothing per write. +]=] +function OpaqueRegistry.MakeCtx(registries: Registries): Ctx + local managerOpaque = registries.Opaque + local managerOpaqueChildren = registries.OpaqueChildren + return { + isOpaque = function(ref: any): boolean + return managerOpaque[ref] == true or globalOpaque[ref] == true + end, + hasOpaqueChildren = function(ref: any): boolean + return managerOpaqueChildren[ref] == true or globalOpaqueChildren[ref] == true + end, + } +end + +return OpaqueRegistry diff --git a/lib/tablemanager2/src/PathHelpers.luau b/lib/tablemanager2/src/PathHelpers.luau new file mode 100644 index 00000000..725f5fc4 --- /dev/null +++ b/lib/tablemanager2/src/PathHelpers.luau @@ -0,0 +1,219 @@ +--!strict +--[=[ + @ignore + @class PathHelpers + + Utility functions for working with paths in nested table structures. + These functions handle path parsing, comparison, and string conversion for the + TableManager system. + + All functions are pure and stateless. +]=] + +--// Types //-- + +--[=[ + @within PathHelpers + @type Path { any } | string + Defines a path to a value in the nested table structure. + Paths are represented as arrays of keys, where each key can be any Lua type (string, number, boolean, etc.). + + For example, the path to access `Data.player.level` would be represented as `{"player", "level"}`. + + Note: Using `any` here is intentional and necessary since Lua tables support any type as a key. +]=] +export type Path = S | "" | { any } +export type PathArray = { any } +export type function PathString(S: type) + if S:is("table") then + return types.intersectionof(types.singleton("[UNKNOWN_PATH]"), types.any) + end + return S +end + +-- Expects a table type `T` and a path type `S`, and recursively resolves the type at that path. +export type function ValueAtPathFn(T: type, S: type): type + if not T:is("table") then + return types.never + end + + -- Array/tuple path: {"Hello", "World"}. Luau doesnt support this yet so we have to fall back to 'any' + if S:is("table") or S:is("string") then + return types.any + end + + if not S:is("singleton") then + error("Path type must be a singleton string or a table of any keys. Defaulting to 'never'.") + return types.never + end + + -- String path: "Hello.World" (existing behavior) + local str = S:value() + if typeof(str) ~= "string" then + error("Path type must be a singleton string or a table of any keys. Defaulting to 'never'.") + return types.never + end + if str == "" then -- if the path is empty, return the original type + return T + end + + local dot = string.find(str, ".", 1, true) + if dot == nil then + local result = T:readproperty(types.singleton(str)) + if result == nil then + error(`Path segment "{str}" does not exist in the table`) + return types.never + end + return result + else + local head = string.sub(str, 1, dot - 1) + local tail = string.sub(str, dot + 1) + local inner = T:readproperty(types.singleton(head)) + if inner == nil then + error(`Path segment "{head}" does not exist in the table`) + return types.never + end + return ValueAtPathFn(inner, types.singleton(tail)) + end +end +export type ValueAtPath = any --ValueAtPathFn -- This is temporarily commented out bc luau has an internal bug preventing proper parsing + +-- Resolves to the *key* type of the indexer of the container found at path `S` in +-- `T` (e.g. `string` for `{ [string]: Item }`, `number` for `{ Item }`). `never` if +-- the container has no indexer (e.g. a record with only named fields). +export type function KeyAtPathFn(T: type, S: type): type + local container = ValueAtPathFn(T, S) + if not container:is("table") then + return types.never + end + local indexer = container:indexer() + if indexer == nil then + return types.never + end + return indexer.index +end +export type KeyAtPath = any --KeyAtPathFn + +-- Resolves to the *element* type of the indexer of the container found at path `S` +-- in `T` (e.g. `Item` for `{ [string]: Item }` or `{ Item }`). `never` if the +-- container has no indexer. +export type function ElementAtPathFn(T: type, S: type): type + local container = ValueAtPathFn(T, S) + if not container:is("table") then + return types.never + end + local indexer = container:indexer() + if indexer == nil then + return types.never + end + return indexer.value +end +export type ElementAtPath = any --ElementAtPathFn + +-------------------------------------------------------------------------------- +--// Class //-- +-------------------------------------------------------------------------------- + +local PathHelpers = {} + +--[=[ + Parses a path string into a Path array. + If the input is already a table, it returns it directly. + + @param pathString The path as a string (e.g., "player.level") or an array of keys + @return The parsed Path array +]=] +function PathHelpers.ParsePath(pathString: Path): PathArray + if typeof(pathString) == "table" then + return pathString :: PathArray + end + return table.freeze(string.split(pathString, ".")) :: PathArray +end + +function PathHelpers.ArePathsEqual(a: Path, b: Path): boolean + if a == b then + return true + end + + local aArray: PathArray = PathHelpers.ParsePath(a) + local bArray: PathArray = PathHelpers.ParsePath(b) + if #aArray ~= #bArray then + return false + end + + for i = 1, #aArray do + if aArray[i] ~= bArray[i] then + return false + end + end + + return true +end + +--[=[ + Converts a path array to a dot-separated string representation. + Useful for debugging and error messages. + + @param path The path array to convert + @return A string representation (e.g., "player.stats.health" or "" for empty path) +]=] +function PathHelpers.PathToString(path: PathArray): string + if #path == 0 then + return "" + end + local parts = table.create(#path) + for i, segment in path do + parts[i] = tostring(segment) + end + return table.concat(parts, ".") +end + +--[=[ + Checks if one path is a prefix of another. + + @param prefix The potential prefix path + @param fullPath The full path to check against + @return true if prefix matches the start of fullPath +]=] +function PathHelpers.IsPrefixPath(prefix: PathArray, fullPath: PathArray): boolean + if #prefix > #fullPath then + return false + end + for i = 1, #prefix do + if prefix[i] ~= fullPath[i] then + return false + end + end + return true +end + +--[=[ + Extracts the parent path and the last key from a path. + + Asserts if `path` is empty (has no last key). + + @param path The path array + @return The parent path array and the last key +]=] +function PathHelpers.GetPathParentAndKey(path: PathArray): (PathArray, any) + local parentPath = table.clone(path) + local key = table.remove(parentPath) + assert(key ~= nil, "Path requires at least one segment") + return parentPath, key +end + +--[=[ + Returns a fresh path equal to `path` with `key` appended. The input path is + not mutated; the returned table is safe to mutate further. + + @param path The base path array + @param key The segment to append + @return A new path array `{...path, key}` +]=] +function PathHelpers.Append(path: PathArray, key: any): PathArray + local result = table.clone(path) + table.insert(result, key) + return result +end + +return PathHelpers diff --git a/lib/tablemanager2/src/ProxyManager.luau b/lib/tablemanager2/src/ProxyManager.luau new file mode 100644 index 00000000..e6a865a5 --- /dev/null +++ b/lib/tablemanager2/src/ProxyManager.luau @@ -0,0 +1,677 @@ +--!strict +--[=[ + @ignore + @class ProxyManager + + Thin proxy front-end over a raw table. ProxyManager only maintains the + proxy graph (creation, parent/key tracking, reparenting) and read-side + metamethods. It performs NO change detection, validation, batching, or + duplicate-reference checks itself. + + ## Architecture + + ### Single Write Integration Point + Every proxy write (`proxy[key] = value`) resolves the live path of the + write (via `_GetLivePath` + the written key) and unwraps any proxy value, + then calls the handler registered via `SetWriteHandler`. The handler + (TableManager's `applyWrite`) owns validation, duplicate-reference checks, + array-append detection, batching, snapshotting, and change detection. + + If no handler is registered, writes apply directly to the underlying + original table — this keeps ProxyManager usable standalone (see + `Tests/ProxyManager.spec.luau`). + + ### Proxy Implementation + Uses a weak table (PROXY_TO_ORIGINAL) to track proxies without polluting + their key space or interfering with metamethods. + + ### Shared Metatable + All proxies share one metatable per TableManager instance for memory efficiency. + Metadata is stored in `_proxyMeta` and looked up dynamically. + + ### Metamethods + - __index: Returns nested proxies for tables, raw values for scalars + - __newindex: Resolves the live write path and delegates to the write handler + - __eq: Allows proxy == proxy comparisons + - __iter: Enables `for k, v in proxy do` iteration + - __len: Returns array length + - __tostring: Returns "TableManager.Data(path)" + - __metatable: Protects metatable from external access +]=] + +local PathHelpers = require("./PathHelpers") +type Path = PathHelpers.Path +type PathArray = PathHelpers.PathArray +type PathString = PathHelpers.PathString +type table = { [any]: any } + +--[[ + Weak table mapping proxies to their original tables. + This allows proxies to be garbage collected when no longer referenced. +]] +local PROXY_TO_ORIGINAL = setmetatable({}, { __mode = "k" }) + +--// Types //-- +type ValueAtPath = PathHelpers.ValueAtPath + +--[=[ + @within ProxyManager + @type Proxy table & { __proxy: true } + + A proxy wraps a table and intercepts read/write operations. +]=] +type function ProxyWrap(T: type, Path: type): type + if not T:is("table") then + return T + end + + if Path:is("string") then + return types.any + end + + if not Path:is("singleton") then + error("Path type must be a singleton string or a table of any keys. Defaulting to 'never'.") + return types.never + end + + T = ValueAtPath(T, Path) + if not T:is("table") then + return T + end + + for k, v in T:properties() do + if v.read and v.read:is("table") then + T:setproperty(k, ProxyWrap(v.read, Path)) + end + end + + T:setproperty(types.singleton("__PROXY__"), types.singleton(true)) + + return T +end +export type Proxy = any -- ProxyWrap + +--[=[ + @within ProxyManager + @interface ProxyMetadata + Original { [any]: any } -- The unwrapped original table + Parent any? -- The original (unwrapped) parent table; nil for the root proxy + Key any? -- The key under which this table lives in its parent; nil for the root proxy + IsArray boolean -- Whether this table is treated as an array + ArrayLength number -- Cached length for arrays + + Metadata stored for each proxy. +]=] +export type ProxyMetadata = { + Original: ValueAtPath & table, -- The unwrapped original table + Parent: any?, -- The original (unwrapped) parent table; nil for the root proxy + Key: any?, -- The key under which this table lives in its parent; nil for the root proxy + IsArray: boolean, -- Whether this table is treated as an array + ArrayLength: number, -- Cached length for arrays +} + +export type ProxyManager = { + IsProxy: (self: ProxyManager, t: any) -> boolean, + GetOriginal: (self: ProxyManager, t: Proxy) -> ValueAtPath, + GetMetadata: (self: ProxyManager, proxy: Proxy) -> ProxyMetadata, + --- Returns the live path from root to this proxy by walking the Parent chain. Returns nil if the proxy is unknown. + GetPath: (self: ProxyManager, proxy: Proxy) -> PathArray?, + --- Returns the existing proxy for an original table, or nil if none exists. + GetProxyFromOriginal: (self: ProxyManager, original: ValueAtPath) -> Proxy?, + CreateProxy: ( + self: ProxyManager, + original: ValueAtPath, + parentOriginal: any?, + key: any? + ) -> Proxy, + --- Sets the single write-interception handler. Every proxy write + --- (`proxy[key] = value`) resolves the live path of the write (including + --- `key`) and calls `handler(path, unwrappedValue)`. If no handler is set, + --- writes apply directly to the underlying original table (used by + --- standalone ProxyManager tests). + SetWriteHandler: (self: ProxyManager, handler: (path: PathArray, value: any) -> ()) -> (), + ReparentProxy: (self: ProxyManager, proxy: Proxy, newParentOriginal: any?, newKey: any?) -> (), + RetargetRoot: (self: ProxyManager, oldRoot: any, newRoot: any) -> (), + --- Update the Key metadata for all direct child proxies of `arrayOriginal` whose + --- numeric key is >= `fromIndex` by adding `delta`. Called by TableManager after + --- every ArrayInsert (+1) or ArrayRemove (-1) so held proxies report the correct path. + ShiftKeys: (self: ProxyManager, arrayOriginal: { [any]: any }, fromIndex: number, delta: number) -> (), + --- Evicts proxy-graph bookkeeping for a table value (and every table nested + --- inside it) that has been detached from the tree. Called by TableManager + --- after removals/replacements so detached subtrees can be garbage collected. + PruneOriginal: (self: ProxyManager, removedValue: any) -> (), + Destroy: (self: ProxyManager) -> (), + + -- Private fields + _proxyMeta: { [any]: ProxyMetadata }, + _originalToProxy: { [any]: Proxy }, + _proxiesByParent: { [any]: { [any]: true } }, -- parentOriginal → set of child proxies + _rootTable: T, + _writeHandler: ((path: PathArray, value: any) -> ())?, + _metatableTemplate: { [any]: any }, + _GetLivePath: (self: ProxyManager, proxy: Proxy) -> PathArray, +} + +-------------------------------------------------------------------------------- +--// Util Functions //-- +-------------------------------------------------------------------------------- + +-- Check if a value is a proxy by looking it up in the weak table. +const function isProxy(t: any): boolean + const valueType = type(t) + return (valueType == "table" or valueType == "userdata") and PROXY_TO_ORIGINAL[t] ~= nil +end + +--[[ + Get the original (unwrapped) table from a proxy. + If the input is not a proxy, returns it unchanged. +]] +const function getOriginal(t: T | Proxy): T + if isProxy(t) then + return PROXY_TO_ORIGINAL[t :: Proxy] :: T + end + return t +end + +--[[ + Walk `root` along `path`, returning the value found or nil if any + intermediate segment is not a table. +]] +const function resolveAtPath(root: any, path: PathArray): any + local current: any = root + for _, key in path do + if type(current) ~= "table" then + return nil + end + current = current[key] + end + return current +end + +--[[ + Classify table shape in one pass. + Returns whether the table is an array plus its array length when true. +]] +const function classifyTable(t: { [any]: any }): (boolean, number) + if type(t) ~= "table" then + return false, 0 + end + + local count = 0 + local maxIndex = 0 + + for key, _ in t do + if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then + return false, 0 + end + count += 1 + maxIndex = math.max(maxIndex, key) + end + + if count == maxIndex then + return true, maxIndex + end + + return false, 0 +end + +-------------------------------------------------------------------------------- +--// Module //-- +-------------------------------------------------------------------------------- + +const ProxyManager = {} +const ProxyManager_MT = { __index = ProxyManager } + +--[=[ + Creates a new ProxyManager instance. +]=] +function ProxyManager.new(rootTable: T): ProxyManager + if type(rootTable) ~= "table" then + error("ProxyManager.new expects rootTable to be a plain table") + end + + const self: ProxyManager = setmetatable({}, ProxyManager_MT) :: any + + -- Weak keys: an unheld proxy collects together with its metadata once it is + -- evicted from the strong maps (PruneOriginal / Destroy). While a table is in + -- the tree, its proxy stays strongly rooted via _originalToProxy, so metadata + -- persistence is unchanged for live data. Metadata values never reference + -- their proxy key, so no ephemeron semantics are needed. + self._proxyMeta = setmetatable({}, { __mode = "k" }) :: any + self._originalToProxy = {} + self._proxiesByParent = {} + self._rootTable = rootTable + self._writeHandler = nil + + -- Create the metatable template copied into each proxy metatable. + self._metatableTemplate = { + __index = function(proxy, key) + const meta = self._proxyMeta[proxy] + assert(meta, "Proxy metadata not found - proxy may have been destroyed") + + const originalTable = meta.Original + const value = originalTable[key] + + -- Return nested proxy for tables + if type(value) == "table" then + -- Check if we already have a proxy for this table + if self._originalToProxy[value] then + return self._originalToProxy[value] + end + + -- Create new proxy for nested table + return self:CreateProxy(value, meta.Original, key) + end + + -- Return raw value for scalars + return value + end, + + __newindex = function(proxy: Proxy, key: any, value: any) + const meta = self._proxyMeta[proxy] + assert(meta, "Proxy metadata not found - proxy may have been destroyed") + + const unwrappedValue = getOriginal(value) + + -- A proxy is bound to the table object it represents (`meta.Original`), + -- not to the path it currently resolves to. If that object has been + -- removed/replaced at its computed live path (e.g. a sibling shifted + -- into its old slot after an ArrayRemove), the proxy is orphaned: + -- write directly to the detached object so it does not bleed into + -- whatever now lives at that path. + const path = self:_GetLivePath(proxy) + const isLive = resolveAtPath(self._rootTable, path) == meta.Original + + if self._writeHandler and isLive then + table.insert(path, key) + self._writeHandler(path, unwrappedValue) + else + -- Standalone mode (no write handler) or an orphaned proxy: apply directly. + meta.Original[key] = unwrappedValue + end + end, + + __eq = function(a, b) + -- Allow proxy == proxy comparisons + return getOriginal(a) == getOriginal(b) + end, + + __iter = function(proxy) + const meta = self._proxyMeta[proxy] :: ProxyMetadata + assert(meta, "Proxy metadata not found - proxy may have been destroyed") + + -- Use next() on the original table, but wrap returned values in proxies + const originalTable = meta.Original + return function(_, key) + const nextKey, nextValue = next(originalTable, key) + if nextKey == nil then + return nil, nil + end + + -- Wrap table values in proxies + if type(nextValue) == "table" then + if self._originalToProxy[nextValue] then + return nextKey, self._originalToProxy[nextValue] :: any + end + + local nestedProxy = self:CreateProxy(nextValue, meta.Original, nextKey) + return nextKey, nestedProxy + end + + return nextKey, nextValue + end, + nil, + nil + end, + + __len = function(proxy) + const meta = self._proxyMeta[proxy] :: ProxyMetadata + assert(meta, "Proxy metadata not found - proxy may have been destroyed") + return #meta.Original + end, + + __tostring = function(proxy) + const meta = self._proxyMeta[proxy] :: ProxyMetadata + if not meta then + return "TableManager.Data(?)" + end + const livePath = self:_GetLivePath(proxy) + if #livePath == 0 then + return "TableManager.Data" + end + return "TableManager.Data(" .. table.concat(livePath, ".") .. ")" + end, + + __metatable = "Locked", + } + + return self +end + +--[=[ + Sets the single write-interception handler. See the `SetWriteHandler` type + declaration for the contract. +]=] +function ProxyManager:SetWriteHandler(handler: (path: PathArray, value: any) -> ()) + self._writeHandler = handler :: any +end + +--- Check if a value is a proxy. +function ProxyManager.IsProxy(self: ProxyManager, t: any): boolean + return isProxy(t) +end + +--- Get the original (unwrapped) table from a proxy. If the input is not a proxy, returns it unchanged. +function ProxyManager.GetOriginal(self: ProxyManager, t: Proxy | T): T + return getOriginal(t) +end + +--- Get the metadata for a proxy. +function ProxyManager.GetMetadata(self: ProxyManager, proxy: Proxy): ProxyMetadata + return self._proxyMeta[proxy] +end + +--- Get the live path from root to this proxy by walking the Parent chain. +function ProxyManager.GetPath(self: ProxyManager, proxy: Proxy): PathArray? + if not self._proxyMeta[proxy] then + return nil + end + return self:_GetLivePath(proxy) +end + +--- Get the proxy for an original table, if it exists. +function ProxyManager.GetProxyFromOriginal(self: ProxyManager, original: T): Proxy? + return self._originalToProxy[original] :: any +end + +--[=[ + Walk the Parent chain from `proxy` up to the root and return the assembled path. + O(depth). Returns a fresh table each call — safe to mutate. +]=] +function ProxyManager._GetLivePath(self: ProxyManager, proxy: Proxy): PathArray + const meta = self._proxyMeta[proxy] + if meta == nil or meta.Parent == nil then + return {} + end + + const keys: PathArray = {} + local current = meta + while current ~= nil and current.Key ~= nil do + keys[#keys + 1] = current.Key -- collect leaf→root; reverse below + if current.Parent == nil then + break + end + const parentProxy = self._originalToProxy[current.Parent] + if parentProxy == nil then + break + end + current = self._proxyMeta[parentProxy] + end + + -- Reverse in place: collected in leaf→root order, callers expect root→leaf. + local lo, hi = 1, #keys + while lo < hi do + keys[lo], keys[hi] = keys[hi], keys[lo] + lo += 1 + hi -= 1 + end + + return keys +end + +--[=[ + Create a new proxy for a table at the given path. + + @param original -- The original table to wrap + @param parentOriginal -- The unwrapped parent table (nil for root proxy) + @param key -- The key under which `original` lives in its parent (nil for root proxy) +]=] +function ProxyManager.CreateProxy( + self: ProxyManager, + original: ValueAtPath, + parentOriginal: any?, + key: any? +): Proxy + if type(original) ~= "table" then + return original :: any + end + + -- Return existing proxy if already created + if self._originalToProxy[original] then + return self._originalToProxy[original] :: any + end + + -- Create new proxy + const proxy = newproxy(true) :: Proxy + local MT = getmetatable(proxy :: any) + for k, v in self._metatableTemplate do + MT[k] = v + end + table.freeze(MT) -- Per-proxy metatable is immutable for safety + + -- Store bidirectional mapping + PROXY_TO_ORIGINAL[proxy] = original + self._originalToProxy[original] = proxy + + -- Store metadata + const isArr, arrayLength = classifyTable(original) + self._proxyMeta[proxy] = { + Original = original, + Parent = parentOriginal, + Key = key, + IsArray = isArr, + ArrayLength = arrayLength, + } + + -- Register in parent lookup so ShiftKeys can find child proxies. + -- Weak-keyed set: evicted/unheld proxies drop out instead of accumulating. + if parentOriginal ~= nil then + if not self._proxiesByParent[parentOriginal] then + self._proxiesByParent[parentOriginal] = setmetatable({}, { __mode = "k" }) :: any + end + self._proxiesByParent[parentOriginal][proxy] = true + end + + return proxy +end + +--[=[ + Reparent an existing proxy to a new parent/key without changing its original table. +]=] +function ProxyManager.ReparentProxy(self: ProxyManager, proxy: Proxy, newParentOriginal: any?, newKey: any?) + const meta = self._proxyMeta[proxy] + if meta == nil then + error("Proxy metadata not found - proxy may have been destroyed") + end + + const oldParent = meta.Parent + if oldParent ~= nil then + const oldChildren = self._proxiesByParent[oldParent] + if oldChildren ~= nil then + oldChildren[proxy] = nil + if next(oldChildren) == nil then + self._proxiesByParent[oldParent] = nil + end + end + end + + meta.Parent = newParentOriginal + meta.Key = newKey + + if newParentOriginal ~= nil then + if not self._proxiesByParent[newParentOriginal] then + self._proxiesByParent[newParentOriginal] = setmetatable({}, { __mode = "k" }) :: any + end + self._proxiesByParent[newParentOriginal][proxy] = true + end +end + +--[=[ + Retarget the root proxy from `oldRoot` to `newRoot`, preserving the root + proxy's identity (callers may hold `manager.Proxy`). Updates `_rootTable`, + the root proxy's metadata, and the bidirectional mappings, then prunes the + stale proxy bookkeeping of the old subtree. + + `oldRoot` must be the current root proxy's original. Nested proxies for + `newRoot` are recreated lazily on first access via `__index`. +]=] +function ProxyManager.RetargetRoot(self: ProxyManager, oldRoot: any, newRoot: any) + if type(newRoot) ~= "table" then + error("RetargetRoot expects newRoot to be a table") + end + const rootProxy = self._originalToProxy[oldRoot] + if rootProxy == nil then + error("RetargetRoot: oldRoot is not the current root proxy's original") + end + + -- Point the manager's root reference at the new table (used by liveness checks). + self._rootTable = newRoot + + -- Retarget the root proxy's metadata to the new original. + const meta = self._proxyMeta[rootProxy] + if meta ~= nil then + const isArr, arrayLength = classifyTable(newRoot) + meta.Original = newRoot + meta.IsArray = isArr + meta.ArrayLength = arrayLength + end + + -- Move the bidirectional mappings onto the new original. + self._originalToProxy[oldRoot] = nil + self._originalToProxy[newRoot] = rootProxy + PROXY_TO_ORIGINAL[rootProxy] = newRoot + + -- Evict stale proxy bookkeeping for the old subtree. The old root's mapping + -- is already cleared above, so `visit(oldRoot)` only prunes its former + -- descendants; the (now newRoot-backed) root proxy is never touched. + self:PruneOriginal(oldRoot) +end + +--[=[ + Update the `Key` metadata for all direct child proxies of `arrayOriginal` + whose numeric key is >= `fromIndex` by adding `delta`. + + Call with `delta = 1` after `table.insert(array, pos, value)` and + `delta = -1` after `table.remove(array, index)` (passing `index + 1` + as `fromIndex` for removes so the removed slot itself is not shifted). +]=] +function ProxyManager.ShiftKeys(self: ProxyManager, arrayOriginal: { [any]: any }, fromIndex: number, delta: number) + const children = self._proxiesByParent[arrayOriginal] + if children == nil then + return + end + for childProxy in children do + const meta = self._proxyMeta[childProxy] + if meta ~= nil and type(meta.Key) == "number" and meta.Key >= fromIndex then + meta.Key += delta + end + end +end + +--[=[ + Evicts proxy-graph bookkeeping for `removedValue` and every table nested + inside it, after the value has been detached from the tree. + + Tables whose proxy still resolves to a live location are left tracked: this + covers `MoveTo`/`Swap` (which reparent the proxy before the old slot is + cleared) and second references under `DuplicateReferenceMode = "allow"`. + + `_proxyMeta` entries are intentionally NOT removed here: the map is + weak-keyed, so an unheld proxy collects together with its metadata, while an + externally-held proxy keeps its metadata and continues to function as an + orphan (reads and direct writes against the detached table still work). + + Note: traversing a HELD orphan proxy after pruning re-registers its nested + tables in the strong maps (they are only evicted again on Destroy). Avoid + retaining and walking orphan proxies long-term. +]=] +function ProxyManager.PruneOriginal(self: ProxyManager, removedValue: any) + if type(removedValue) ~= "table" then + return + end + + const memo: { [any]: true } = {} + + const function visit(t: { [any]: any }) + if memo[t] then + return + end + memo[t] = true + + const proxy = self._originalToProxy[t] + if proxy ~= nil then + -- Liveness guard: skip tables that still resolve at their proxy's + -- live path (re-homed by MoveTo/Swap, or duplicate references). + const stillLive = resolveAtPath(self._rootTable, self:_GetLivePath(proxy)) == t + if not stillLive then + self._originalToProxy[t] = nil + const meta = self._proxyMeta[proxy] + if meta ~= nil and meta.Parent ~= nil then + const siblings = self._proxiesByParent[meta.Parent] + if siblings ~= nil then + siblings[proxy] = nil + if next(siblings) == nil then + self._proxiesByParent[meta.Parent] = nil + end + end + end + self._proxiesByParent[t] = nil + end + end + + for _, child in t do + if type(child) == "table" then + visit(child) + end + end + end + + visit(removedValue) +end + +--[=[ + Clean up all proxies and metadata. +]=] +function ProxyManager.Destroy(self: ProxyManager) + -- Clear weak table entries (they'll be GC'd automatically, but we can help) + for proxy in self._proxyMeta do + PROXY_TO_ORIGINAL[proxy] = nil + end + + -- Clear all metadata + table.clear(self._proxyMeta) + table.clear(self._originalToProxy) + table.clear(self._proxiesByParent) + + -- Per-proxy metatable closures capture `self`, so any externally-held proxy + -- pins this manager after Destroy; drop the root reference so it cannot pin + -- the entire data tree along with it. + self._rootTable = nil :: any + self._writeHandler = nil +end + +-------------------------------------------------------------------------------- +--// Instance-Free Statics //-- +-------------------------------------------------------------------------------- + +--- Returns true if `t` is a proxy created by any ProxyManager. +function ProxyManager.IsProxyValue(t: any): boolean + return isProxy(t) +end + +--- Returns the original (unwrapped) table behind a proxy, or `t` unchanged if it isn't a proxy. +function ProxyManager.Unwrap(t: T | Proxy): T + return getOriginal(t) +end + +--[[ + Classify table shape in one pass. + Returns whether the table is an array plus its array length when true. + A table is considered an array when every key is a positive integer and + the set of keys is exactly `{1, .., maxIndex}` (so `{}` is a length-0 array). +]] +function ProxyManager.ClassifyTable(t: { [any]: any }): (boolean, number) + return classifyTable(t) +end + +return ProxyManager diff --git a/lib/tablemanager2/src/SchemaNavigator.luau b/lib/tablemanager2/src/SchemaNavigator.luau new file mode 100644 index 00000000..3286ba32 --- /dev/null +++ b/lib/tablemanager2/src/SchemaNavigator.luau @@ -0,0 +1,112 @@ +--!strict +--[=[ + @class SchemaNavigator + @ignore + + Resolves and validates path-based schema checks using T metadata + introspection (`T.GetMeta`). +]=] + +const PathHelpers = require("./PathHelpers") +const T = require("../T") + +type Path = PathHelpers.Path + +export type Check = (value: any) -> (boolean, string?) +export type CheckMeta = + { kind: "interface" | "strictInterface" | "partialInterface", fields: { [any]: Check } } + | { kind: "optional", innerCheck: Check } + | { kind: "array", valueCheck: Check } + +const SchemaNavigator = {} + +const function getMeta(check: Check): CheckMeta? + return T.GetMeta(check) :: any +end + +const function unwrapOptionalForTraversal(check: Check): Check + local current = check + while true do + const meta = getMeta(current) + if meta and meta.kind == "optional" then + current = meta.innerCheck + else + break + end + end + return current +end + +--[=[ + @within SchemaNavigator + Walks the schema's metadata tree along the given path and returns the + `Check` that covers the node at that path, or `nil` if the path is not + described by the schema (indicating the path is unconstrained). + + Optional wrappers are transparently unwrapped during traversal so that a + field typed as `t.optional(t.interface(...))` is still navigable. + + @param schema Check -- The root schema check to navigate. + @param path Path -- The path to resolve within the schema. + @return Check? -- The check at the resolved path, or nil if unconstrained. +]=] +function SchemaNavigator.Navigate(schema: Check, path: Path): Check? + const parsedPath = PathHelpers.ParsePath(path) + local current: Check? = schema + + for _, segment in parsedPath do + if not current then + return nil + end + + const traversable = unwrapOptionalForTraversal(current) + const meta = getMeta(traversable) + if not meta then + return nil + end + + if meta.kind == "interface" or meta.kind == "strictInterface" or meta.kind == "partialInterface" then + current = meta.fields[segment] + elseif meta.kind == "array" then + if type(segment) ~= "number" then + return nil + end + current = meta.valueCheck + elseif meta.kind == "optional" then + current = meta.innerCheck + else + error("Unsupported schema kind: " .. tostring(meta.kind)) + end + end + + return current +end + +--[=[ + @within SchemaNavigator + Resolves the check at the given path within the schema (via `Navigate`) + and runs it against `value`. Returns `true` when the path is unconstrained + by the schema, so only paths explicitly described by the schema are + enforced. + + @param schema Check -- The root schema check. + @param path Path -- The path of the value being validated. + @param value any -- The value to validate. + @return boolean -- Whether the value passed validation. + @return string? -- An error message when validation fails. +]=] +function SchemaNavigator.Validate(schema: Check, path: Path, value: any): (boolean, string?) + const check = SchemaNavigator.Navigate(schema, path) + if not check then + return true :: any + end + + const ok, err = check(value) + if ok then + return true :: any + end + + return false, err or "Schema validation failed" +end + +return SchemaNavigator diff --git a/lib/tablemanager2/src/Shadow.luau b/lib/tablemanager2/src/Shadow.luau new file mode 100644 index 00000000..53682952 --- /dev/null +++ b/lib/tablemanager2/src/Shadow.luau @@ -0,0 +1,174 @@ +--!strict +--[=[ + @ignore + @class Shadow + + A persistent, plain (unfrozen) deep-copied mirror of live data for + observed regions only — the diff baseline ("old") that `TableManager:Flush` + reads from, replacing a fresh per-write snapshot. Opaque values/children + are kept BY REFERENCE (never copied), mirroring `Diff.snapshot`'s opacity + handling, so identity comparison against them still works. + + The mirror is seeded lazily: `Materialize` is only ever called for a path + some caller has independently established is worth tracking (see + `Coverage.IsTrackable`), so regions nobody observes are simply absent from + the mirror. Plain (unfrozen) tables make `Reconcile` a cheap child-reference + swap and keep ancestor views automatically consistent — callers must not + mutate a value handed to them as `oldValue` (the same contract the rest of + TableManager already relies on for frozen snapshot values). +]=] + +const PathHelpers = require("./PathHelpers") +const Diff = require("./Diff") + +--// Types //-- +type PathArray = PathHelpers.PathArray + +export type Shadow = { + _mirror: any, + --- Returns the last-flushed value at `path` (the diff baseline). `nil` + --- when `path` (or an ancestor of it) was never materialized. + Get: (self: Shadow, path: PathArray) -> any, + --- Sets the mirror at `path` to a fresh `copyForShadow` of `live`'s + --- current value there. Requires `path`'s parent to already exist in the + --- mirror; use `Materialize` to seed a never-before-tracked path. + Reconcile: (self: Shadow, path: PathArray, live: any, ctx: Diff.Ctx?) -> (), + --- Seeds the mirror spine down to `path` (creating missing intermediate + --- tables) and then `Reconcile`s the leaf. Safe to call on an + --- already-materialized path. + Materialize: (self: Shadow, path: PathArray, live: any, ctx: Diff.Ctx?) -> (), + --- Drops the mirror subtree at `path`. No-op if `path`'s parent isn't materialized. + Release: (self: Shadow, path: PathArray) -> (), +} + +const Shadow = {} +const Shadow_MT = { __index = Shadow } + +-- --------------------------------------------------------------------------- +-- Internal helpers +-- --------------------------------------------------------------------------- + +-- Mirrors Diff.snapshot's shape decisions, but produces a plain (unfrozen) +-- table and keeps opaque values/children by reference instead of cloning. +local function copyForShadow(value: any, ctx: Diff.Ctx?): any + if type(value) ~= "table" then + return value + end + if ctx ~= nil and ctx.isOpaque(value) then + return value + end + const childrenOpaque = ctx ~= nil and ctx.hasOpaqueChildren(value) + const out = {} + for k, v in value do + if childrenOpaque and type(v) == "table" then + out[k] = v + else + out[k] = copyForShadow(v, ctx) + end + end + return out +end + +-- Walks `root` along `path`, returning the value found or nil if any +-- intermediate segment is not a table. +local function resolveAtPath(root: any, path: PathArray): any + local current: any = root + for _, key in path do + if type(current) ~= "table" then + return nil + end + current = current[key] + end + return current +end + +-- --------------------------------------------------------------------------- +-- Public API +-- --------------------------------------------------------------------------- + +function Shadow.new(): Shadow + return (setmetatable({ _mirror = nil }, Shadow_MT) :: any) :: Shadow +end + +--[=[ + Returns the last-flushed value at `path` (the diff baseline). `nil` when + `path` (or an ancestor of it) was never materialized — callers must not + treat this as "the real value is nil" without checking coverage first. +]=] +function Shadow.Get(self: Shadow, path: PathArray): any + return resolveAtPath(self._mirror, path) +end + +--[=[ + Sets the mirror at `path` to a fresh `copyForShadow` of `live`'s current + value there. Requires `path`'s parent to already exist in the mirror (root + always does); use `Materialize` to seed a never-before-tracked path. +]=] +function Shadow.Reconcile(self: Shadow, path: PathArray, live: any, ctx: Diff.Ctx?) + const value = resolveAtPath(live, path) + const copy = copyForShadow(value, ctx) + if #path == 0 then + self._mirror = copy + return + end + const parentPath, key = PathHelpers.GetPathParentAndKey(path) + const parent = resolveAtPath(self._mirror, parentPath) + if type(parent) == "table" then + parent[key] = copy + end +end + +--[=[ + Seeds the mirror spine down to `path` (creating any missing intermediate + tables) and then `Reconcile`s the leaf. Safe to call on an + already-materialized path — the spine walk is then a no-op and the leaf is + just freshly re-copied. + + A newly-created intermediate node is seeded with a full `copyForShadow` of + its CURRENT live value, not an empty table: coverage here can come from a + connected global Signal, which makes every path independently trackable + without ever materializing a whole subtree up front. Without this, a + sibling key that's never itself the target of a write (e.g. `mana` next to + a repeatedly-written `health`) would be silently absent from the mirror — + and a LATER wholesale replacement of the shared ancestor would fail to + report that sibling's removal, since the diff sees it as "already absent" + on both sides instead of genuinely removed. +]=] +function Shadow.Materialize(self: Shadow, path: PathArray, live: any, ctx: Diff.Ctx?) + if #path == 0 then + self:Reconcile(path, live, ctx) + return + end + if self._mirror == nil then + self._mirror = {} + end + local node = self._mirror + const partialPath: { any } = {} + for i = 1, #path - 1 do + const segment = path[i] + table.insert(partialPath, segment) + if type(node[segment]) ~= "table" then + node[segment] = copyForShadow(resolveAtPath(live, partialPath), ctx) + end + node = node[segment] + end + self:Reconcile(path, live, ctx) +end + +--[=[ + Drops the mirror subtree at `path` (e.g. once nothing covers it anymore). + No-op if `path`'s parent isn't materialized. +]=] +function Shadow.Release(self: Shadow, path: PathArray) + if #path == 0 then + self._mirror = nil + return + end + const parentPath, key = PathHelpers.GetPathParentAndKey(path) + const parent = resolveAtPath(self._mirror, parentPath) + if type(parent) == "table" then + parent[key] = nil + end +end + +return Shadow diff --git a/lib/tablemanager2/src/TMTypes.luau b/lib/tablemanager2/src/TMTypes.luau new file mode 100644 index 00000000..31e17cf7 --- /dev/null +++ b/lib/tablemanager2/src/TMTypes.luau @@ -0,0 +1,433 @@ +--!strict + +--// Imports //-- +const PathHelpers = require("./PathHelpers") +const ProxyManagerModule = require("./ProxyManager") +const ListenerRegistryModule = require("./ListenerRegistry") +const ChangeDetectorModule = require("./ChangeDetector") +const SchemaNavigatorModule = require("./SchemaNavigator") +const ArrayDiffModule = require("./ArrayDiff") +const BatchUtilsModule = require("./BatchUtils") +const OpaqueRegistryModule = require("./OpaqueRegistry") +const IgnoreTrieModule = require("./IgnoreTrie") +const ShadowModule = require("./Shadow") +const DiffModule = require("./Diff") +const Signal = require("../Signal") +const JanitorModule = require("../Janitor") + +--// Types //-- +type Path = PathHelpers.Path +type PathArray = PathHelpers.PathArray +type ValueAtPath = PathHelpers.ValueAtPath +type KeyAtPath = PathHelpers.KeyAtPath +type ElementAtPath = PathHelpers.ElementAtPath +type Janitor = JanitorModule.Janitor +type ListenerRegistry = ListenerRegistryModule.ListenerRegistry +type ChangeDetector = ChangeDetectorModule.ChangeDetector +type ChangeMetadata = ChangeDetectorModule.ChangeMetadata +type ListenerOptions = ListenerRegistryModule.ListenerOptions +type Connection = ListenerRegistryModule.Connection +type Signal = Signal.Signal +type MoveMetadata = ArrayDiffModule.MoveMetadata +type SchemaCheck = SchemaNavigatorModule.Check +type BatchState = BatchUtilsModule.BatchState +type OpaqueRegistries = OpaqueRegistryModule.Registries +type IgnoreTrieNode = IgnoreTrieModule.Node +type Shadow = ShadowModule.Shadow +type DiffNode = DiffModule.DiffNode + +export type Proxy = ProxyManagerModule.Proxy + +-- The value returned by `Opaque`/`GlobalOpaque`/`OpaqueChildren`/ +-- `GlobalOpaqueChildren` (see `OpaqueRegistry`). `Set`/`ArrayInsert` unwrap it +-- at write time; the literal value stored at the path is always the bare inner. +export type OpaqueWrapper = OpaqueRegistryModule.Wrapper + +export type DuplicateReferenceMode = "error" | "warn" | "allow" | "move" | "copy" + +-- Re-export of `ListenerRegistry.FireMode` for config-surface convenience. +export type ListenerFireMode = ListenerRegistryModule.FireMode + +-- Controls how the per-change Signals (`ValueChanged`, `KeyAdded`, etc.) are +-- dispatched. Defined here rather than in the `Signal` package itself since +-- it's a TableManager-level dispatch policy, not a native `Signal` method: +-- "Immediate"/"Deferred"/"Bindable" map onto `Signal:Fire`/`:FireDeferred`/ +-- `:FireBindable`; "Coalesced" is implemented in `Emitter.luau` by wrapping +-- `:Fire` (see `Emitter.fireSignal`). +export type SignalFireMode = "immediate" | "deferred" | "bindable" | "coalesced" + +-- Controls WHEN `_doFlush` (the shadow diff + fire + reconcile cycle) runs for +-- a direct write. "immediate" (default) runs it synchronously, same as today. +-- "coalesced" defers it to frame-end (`task.defer`) and merges every flush +-- request made before then into one flush at their common ancestor path, so a +-- frame with N writes under one subtree costs one diff/fire instead of N. See +-- `CoalescedFlush.luau`. +export type FlushMode = "immediate" | "coalesced" + +-------------------------------------------------------------------------------- +--// Link type shapes (implemented by LinkGroup.luau / Linker.luau) //-- +-------------------------------------------------------------------------------- + +type opKind = "Set" | "ArrayInsert" | "ArrayRemove" | "ArraySet" + +-- An operation that has ALREADY been applied to the shared raw, awaiting replay +-- via `_NotifyApplied`. `Path` is in the receiving manager's own coordinates. +export type AppliedOp = { + Kind: opKind, + Path: PathArray, + NewValue: any?, + OldValue: any?, + Index: number?, + -- Phase B (linked fan-out): the origin's already-computed diff node for a + -- "Set", so a recipient can dispatch it WITHOUT re-running `Diff.diff`. Only + -- populated when the origin had no opacity active (`OriginHasNoOpacity`) — + -- the diff tree's shape is otherwise per-manager opacity-dependent. + Diff: DiffNode?, + -- True when the origin's opacity ctx was nil at fan-out time. A recipient + -- may only reuse `Diff` when it ALSO has no opacity active. + OriginHasNoOpacity: boolean?, +} + +-- An op relative to the shared object, computed by the fan-out hooks before +-- being translated into each member's own coordinates. +export type RelativeOp = { + Kind: opKind, + RelativePath: PathArray, + NewValue: any?, + OldValue: any?, + Index: number?, + -- See `AppliedOp.Diff`/`OriginHasNoOpacity`; carried through `NotifyChange`. + Diff: DiffNode?, + OriginHasNoOpacity: boolean?, +} + +-- Payload shape passed from `fireArrayOperation` for array-op fan-out. +export type ArrayOpPayload = { + NewValue: any?, + OldValue: any?, + Index: number?, +} + +-- A positional 2-tuple { manager, path } naming a shared node inside a TM. +-- `manager` is typed loosely (`any`, not `TableManagerLike`) to avoid an +-- irregular recursive instantiation of `TM_Internal` (TM_Internal -> +-- LinkGroup -> TableManagerLike -> TM_Internal), which the Luau +-- type-checker cannot resolve and would poison every field of `TM_Internal`. +export type LinkAnchor = { any | Path } + +export type LinkGroup = { + _object: any, + _members: { any }, + _destroyed: boolean, + MemberRemoved: Signal<(manager: any) -> (), any>, + Dissolved: Signal<() -> ()>, + Add: (self: LinkGroup, manager: any, path: Path?) -> (), + Remove: (self: LinkGroup, manager: any) -> (), + Members: (self: LinkGroup) -> { any }, + HasMember: (self: LinkGroup, manager: any) -> boolean, + GetAnchorPath: (self: LinkGroup, manager: any) -> PathArray?, + NotifyChange: (self: LinkGroup, originManager: any, op: RelativeOp) -> (), + Destroy: (self: LinkGroup) -> (), +} + +export type Linker = { + With: (self: Linker, other: any, selfPath: Path?, otherPath: Path?) -> (), + Ensure: (self: Linker, path: Path?) -> (), + Unlink: (self: Linker, target: any?) -> (), + GetManagers: (self: Linker) -> { any }, + IsLinkedWith: (self: Linker, other: any) -> boolean, + GetGroups: (self: Linker) -> { LinkGroup }, +} + +-------------------------------------------------------------------------------- +--// TableManager //-- +-------------------------------------------------------------------------------- + +--[=[ + @within TableManager + @interface TableManagerConfig + .Schema SchemaCheck? + .OnValidationFailed (path: PathArray, value: any, err: string) -> ()? + .ListenerFireMode ListenerFireMode? -- Controls how listener callbacks (OnChange, OnValueChange, etc.) are scheduled when fired. Defaults to "immediate". + .SignalFireMode SignalFireMode? -- Controls how the per-change Signals (ValueChanged, KeyAdded, etc.) are scheduled when fired. Defaults to "immediate". + .FlushMode FlushMode? -- Controls WHEN a direct write's diff+fire+reconcile cycle runs. Defaults to "immediate". See `FlushMode`. + .DuplicateReferenceMode DuplicateReferenceMode? -- Experimental + .EnableProxies boolean? -- Defaults to true. When false, `Proxy`/`GetProxy` are unavailable. + .AutoLink boolean? -- When true, this manager auto-links (via `tm.Linker`) with any OTHER AutoLink manager with shared tables + .IgnoredPaths { Path }? -- Paths (and their descendants) that skip diff/snapshot/event work entirely; see `SetPathIgnored`. + + :::caution Auto Linking + AutoLinking is an experimental feature. + AutoLinking only supports linking with other managers that have AutoLink enabled. If you attempt + to link with a manager that does not have AutoLink enabled, the link will not be established. AutoLinking + also only works when you are setting/inserting a managed table directly. It will not automatically link + nested tables that are placed in bulk. You can use `tm.Linker:Ensure(path)` to link nested tables. + ::: +]=] +export type TableManagerConfig = { + Schema: SchemaCheck?, + OnValidationFailed: ((path: PathArray, value: any, err: string) -> ())?, + ListenerFireMode: ListenerFireMode?, + SignalFireMode: SignalFireMode?, + FlushMode: FlushMode?, + DuplicateReferenceMode: DuplicateReferenceMode?, + EnableProxies: boolean?, + AutoLink: boolean?, + IgnoredPaths: { Path }?, +} + +--[=[ + @within TableManager + @interface ForOptions + .FireForExisting boolean? -- Defaults to true: run the handler/transform for items already present at subscribe time. + .Defer boolean? -- Defer the initial fire (honors the registry's deferred-fire mode either way). +]=] +export type ForOptions = { + FireForExisting: boolean?, + Defer: boolean?, +} + +export type TableManager = { + Raw: T, + Proxy: Proxy?, + Linker: Linker, + + -- Signals (fire once per change) + ValueChanged: Signal<(path: PathArray, newValue: any, oldValue: any) -> (), PathArray, any, any>, + KeyAdded: Signal<(path: PathArray, key: any, newValue: any) -> (), PathArray, any, any>, + KeyRemoved: Signal<(path: PathArray, key: any, oldValue: any) -> (), PathArray, any, any>, + KeyChanged: Signal<(path: PathArray, key: any, newValue: any, oldValue: any) -> (), PathArray, any, any, any>, + ArrayInserted: Signal<(path: PathArray, index: number, newValue: any) -> (), PathArray, number, any>, + ArrayRemoved: Signal<(path: PathArray, index: number, oldValue: any) -> (), PathArray, number, any>, + ArraySet: Signal<(path: PathArray, index: number, newValue: any, oldValue: any) -> (), PathArray, number, any, any>, + + -- Listener registration (fire for ancestors/descendants) -- + + -- Fires when this path is directly reassigned OR any descendant of it + OnChange: ( + self: TableManager, + path: Path, + callback: (newValue: ValueAtPath, oldValue: ValueAtPath?, metadata: ChangeMetadata) -> (), + options: ListenerOptions? + ) -> Connection, + + --- Fires ONLY when this exact path is directly reassigned (not when a + --- descendant changes). Shorthand for `OnValueChange` with + --- `ListenDepth = 0, ListenDepthStyle = "=="`. + OnValueChange: ( + self: TableManager, + path: Path, + callback: (newValue: ValueAtPath, oldValue: ValueAtPath?, metadata: ChangeMetadata) -> (), + options: ListenerOptions? + ) -> Connection, + + --- Immediately invokes `callback` with the current value at `path` + --- (`oldValue` and `metadata` both `nil`), then behaves like + --- `OnValueChange` for subsequent changes. + Observe: ( + self: TableManager, + path: Path, + callback: (newValue: ValueAtPath, oldValue: ValueAtPath?, metadata: ChangeMetadata?) -> (), + options: ListenerOptions? + ) -> Connection, + + OnKeyAdd: ( + self: TableManager, + path: Path, + callback: (key: any, newValue: any, metadata: ChangeMetadata) -> (), + options: ListenerOptions? + ) -> Connection, + OnKeyRemove: ( + self: TableManager, + path: Path, + callback: (key: any, oldValue: any, metadata: ChangeMetadata) -> (), + options: ListenerOptions? + ) -> Connection, + OnKeyChange: ( + self: TableManager, + path: Path, + callback: (key: any, newValue: ValueAtPath, oldValue: ValueAtPath, metadata: ChangeMetadata) -> (), + options: ListenerOptions? + ) -> Connection, + OnArrayInsert: ( + self: TableManager, + path: Path, + callback: (index: number, newValue: any, metadata: ChangeMetadata) -> (), + options: ListenerOptions? + ) -> Connection, + OnArrayRemove: ( + self: TableManager, + path: Path, + callback: (index: number, oldValue: any, metadata: ChangeMetadata) -> (), + options: ListenerOptions? + ) -> Connection, + OnArraySet: ( + self: TableManager, + path: Path, + callback: (index: number, newValue: any, oldValue: any, metadata: ChangeMetadata) -> (), + options: ListenerOptions? + ) -> Connection, + + -- For / Map: reconcile the items found at `path` (a dictionary OR an array) + -- by identity, running a handler/transform per item with automatic teardown + -- (via a per-item Janitor) when that item leaves or is replaced. See + -- TableManager.luau for full semantics and the identity rules per method. + + --- Identity = key. Re-runs only when a key is added/removed; a value change + --- at an existing key does NOT re-run the handler. + ForKeys: ( + self: TableManager, + path: Path, + handler: (itemJanitor: Janitor, key: KeyAtPath, metadata: ChangeMetadata?) -> (), + options: ForOptions? + ) -> Connection, + + --- Identity = value (multiset; duplicate values are tracked independently). + --- A value moving between keys/indices does NOT re-run the handler. + ForValues: ( + self: TableManager, + path: Path, + handler: (itemJanitor: Janitor, value: ElementAtPath, metadata: ChangeMetadata?) -> (), + options: ForOptions? + ) -> Connection, + + --- Identity = (key, value). Either changing tears down the old item and + --- runs the handler again for the new one. + ForPairs: ( + self: TableManager, + path: Path, + handler: ( + itemJanitor: Janitor, + key: KeyAtPath, + value: ElementAtPath, + metadata: ChangeMetadata? + ) -> (), + options: ForOptions? + ) -> Connection, + + --- Returns a live `TableManager` keyed by `transform`'s return value, with + --- values passed through unchanged. Re-runs `transform` only when the + --- source key changes; a value change at an existing key refreshes the + --- output value WITHOUT re-running `transform`. Destroying the returned + --- manager disconnects the source subscription; destroying the SOURCE does + --- NOT cascade-destroy the returned manager. + MapKeys: ( + self: TableManager, + path: Path, + transform: (entryJanitor: Janitor, key: KeyAtPath, value: ElementAtPath) -> any + ) -> TableManager, + + --- Returns a live `TableManager` keyed by the source key, with each entry + --- recomputed via `transform` whenever its value changes. Same lifecycle + --- rules as `MapKeys`. + MapValues: ( + self: TableManager, + path: Path, + transform: (entryJanitor: Janitor, value: ElementAtPath, key: KeyAtPath) -> any + ) -> TableManager, + + --- Returns a live `TableManager` keyed by `transform`'s returned key, with + --- the entry recomputed whenever the source key or value changes. On an + --- output-key collision, the last write wins. Same lifecycle rules as + --- `MapKeys`. + MapPairs: ( + self: TableManager, + path: Path, + transform: (entryJanitor: Janitor, key: KeyAtPath, value: ElementAtPath) -> (any, any) + ) -> TableManager, + + -- Helper methods + Get: (self: TableManager, path: Path, suppressNilPartialPaths: boolean?) -> ValueAtPath, + GetProxy: (self: TableManager, path: Path, suppressNilPartialPaths: boolean?) -> Proxy, + Set: (self: TableManager, path: Path, value: ValueAtPath | OpaqueWrapper) -> (), + ArrayInsert: (self: TableManager, arrPath: Path | Proxy, newValue: any) -> () + & (self: TableManager, arrPath: Path | Proxy, index: number, newValue: any) -> (), + ArrayRemove: (self: TableManager, arrPath: Path | Proxy, index: number) -> any?, + ArrayRemoveFirstValue: (self: TableManager, arrPath: Path | Proxy, valueToFind: any) -> number?, + ArraySwapRemove: (self: TableManager, arrPath: Path | Proxy, index: number) -> any?, + ArraySwapRemoveFirstValue: (self: TableManager, arrPath: Path | Proxy, valueToFind: any) -> number?, + MoveTo: (self: TableManager, currentPath: Path, newPath: Path) -> (), + CopyTo: (self: TableManager, currentPath: Path, newPath: Path) -> (), + Swap: (self: TableManager, a: Path, b: Path) -> (), + -- ForceNotify: (self: TableManager, path: Path | Proxy) -> (), + Batch: (self: TableManager, fn: () -> ()) -> (), + Suspend: (self: TableManager) -> (), + Resume: (self: TableManager) -> (), + + --- Diffs the persistent shadow baseline against the live value at `path` + --- (the whole tree when omitted), fires any resulting events, then + --- reconciles the shadow to the current live value. A no-op when nothing + --- observes `path`. Outside a batch this runs immediately; inside one it + --- just marks the branch dirty for `Resume`'s flush, so an explicit + --- `Flush` can never break batch atomicity. Also the supported way to + --- surface a mutation made by external code that bypassed `Set`/`Proxy`. + Flush: (self: TableManager, path: Path?) -> (), + + --- Marks `path` (and every descendant of it) as ignored or not: an ignored + --- write still happens, but skips all diff/snapshot/event work. See + --- `TableManagerConfig.IgnoredPaths` for the config-time equivalent. + SetPathIgnored: (self: TableManager, path: Path, ignored: boolean) -> (), + + + --- Returns a PLAIN TableManager rooted at the shared table found at `target` + --- (a proxy, a path, or a raw value already in this manager's tree), linked + --- to this manager via `Link` so writes on either side fan out to the other. + Extend: (self: TableManager, target: Proxy | Path, config: ExtendConfig?) -> TableManager>, + + Destroy: (self: TableManager) -> (), +} + +export type ExtendConfig = { + -- Called when the extended manager's anchor diverges (the shared object is + -- removed/replaced in this manager's tree) and it leaves the link group. + -- Equivalent to listening on the group's `MemberRemoved` for the extended manager. + OnDetached: (() -> ())?, +} + +export type TM_Internal = TableManager & { + -- fields + _proxyManager: ProxyManagerModule.ProxyManager?, + _listenerRegistry: ListenerRegistry, + _changeDetector: ChangeDetector, + -- Set from `Config.SignalFireMode`; controls `Emitter.fireSignal` dispatch. + _signalFireMode: SignalFireMode, + -- Lazily created by `Emitter.fireSignalCoalesced` when `SignalFireMode == "Coalesced"`. + _signalCoalesceState: { [any]: { Args: { n: number, [number]: any } } }?, + _schema: SchemaCheck?, + _onValidationFailed: ((path: PathArray, value: any, err: string) -> ())?, + _duplicateReferenceMode: DuplicateReferenceMode, + _isDuplicateMoveInProgress: boolean?, + _Destroyed: boolean?, + -- Batch state + _batchDepth: number, + _batch: BatchState?, + + -- Set from `Config.FlushMode`; controls `CoalescedFlush.Request` routing. + _flushMode: FlushMode, + -- The running common-ancestor path of every flush requested since the last + -- coalesced flush drained, or nil when nothing is pending. See `CoalescedFlush`. + _pendingFlushRoot: PathArray?, + -- True while a `task.defer` is outstanding to drain `_pendingFlushRoot`. + _coalescedFlushScheduled: boolean?, + + -- Link state + _linkGroups: { LinkGroup }?, + _suppressLinkFanOut: boolean?, + -- Set from `Config.AutoLink`; gates the direct write-path auto-link hook. + _autoLink: boolean?, + _janitor: Janitor, + + _opaqueRegistries: OpaqueRegistries, + _ignoreTrie: IgnoreTrieNode, + _shadow: Shadow, + + -- Re-fires listeners/signals for an op that has ALREADY been applied to the + -- shared raw, WITHOUT mutating or snapshot-diffing. Used only by LinkGroup + -- fan-out; `op.Path` is in this manager's own coordinates. + _NotifyApplied: (self: TM_Internal, op: AppliedOp) -> (), + _doFlush: (self: TM_Internal, path: PathArray) -> (), +} + +export type TableManagerLike = TM_Internal<{[any]:any}> + +return {} diff --git a/lib/tablemanager2/src/TableManager.luau b/lib/tablemanager2/src/TableManager.luau new file mode 100644 index 00000000..78f6cb4d --- /dev/null +++ b/lib/tablemanager2/src/TableManager.luau @@ -0,0 +1,1367 @@ +--!strict +--[=[ + @class TableManager + + TableManager is a wrapper around luau tables that provide easy and automatic + change tracking, validation, and listener management. TableManager is designed + to handle the bulk of your volatile data management needs, emitting detailed change + events and snapshots for any changes made to the managed table or its descendants — all + without needing to manually fire events or manage listener connections. + + ### What is TableManager good for? + - Tracking changes to nested tables and arrays. + - Emitting detailed change events for any modifications. + - Providing snapshots of the current state for debugging or synchronization. + - Integrating with ProfileStore for easy management of player data. + + ### What TableManager is Not + - TableManager is not a state management library, and does not include any opinionated + features for structuring your data, managing side effects, or integrating with + other systems. It is purely a change tracking and notification system for tables. + - TableManager is not intended to be used with tables that are mutated by external code + without going through TableManager's API. + - TableManager is not meant for data with a high frequency of updates. It focuses on providing + detailed and accurate change information, which can be expensive to generate for large or + rapidly changing data. + + + ### Usage + + ```lua + const manager = TableManager.new({ + player = { health = 100, level = 5 }, + settings = { volume = 80 } + }) + + -- Listen to direct changes only + manager:OnChange("player", function(newValue, oldValue, metadata) + + end) + + -- Listen to all changes (including descendants) + manager:OnValueChange("player", function(newValue, oldValue, metadata) + + end) + + -- Array operations with ancestor notifications + manager:ArrayInsert("player.items", "Sword") + ``` + + ## Best Practices + - Avoid keeping large datastructures within arrays contained within a tablemanager. It is best + to manage the array separately and have a TableManager for each object. + + - Avoid mixed tables. + + - Utilize pure string keys so the linter can try to infer your types. +]=] + +--// Imports //-- +const T = require("../T") +const Signal = require("../Signal") +const Janitor = require("../Janitor") +const PathHelpers = require("./PathHelpers") +const BatchUtilsModule = require("./BatchUtils") +const BatchFlushModule = require("./BatchFlush") +const ProxyManagerModule = require("./ProxyManager") +const ListenerRegistryModule = require("./ListenerRegistry") +const ChangeDetectorModule = require("./ChangeDetector") +const SchemaNavigatorModule = require("./SchemaNavigator") +const LinkGroupModule = require("./LinkGroup") +const LinkerModule = require("./Linker") +const Types = require("./TMTypes") +const EmitterModule = require("./Emitter") +const MutatorModule = require("./Mutator") +const OpaqueRegistryModule = require("./OpaqueRegistry") +const IgnoreTrieModule = require("./IgnoreTrie") +const ShadowModule = require("./Shadow") +const CoverageModule = require("./Coverage") +const ForMapModule = require("./ForMap") +const CoalescedFlushModule = require("./CoalescedFlush") + +--// Types //-- +type Path = PathHelpers.Path +type PathArray = PathHelpers.PathArray +type ValueAtPath = PathHelpers.ValueAtPath +type KeyAtPath = PathHelpers.KeyAtPath +type ElementAtPath = PathHelpers.ElementAtPath +type ListenerRegistry = ListenerRegistryModule.ListenerRegistry +type ChangeDetector = ChangeDetectorModule.ChangeDetector +type ChangeMetadata = ChangeDetectorModule.ChangeMetadata +type ListenerOptions = ListenerRegistryModule.ListenerOptions +type Connection = ListenerRegistryModule.Connection +type Signal = Signal.Signal +type Janitor = Janitor.Janitor +type SchemaCheck = SchemaNavigatorModule.Check +type BatchState = BatchUtilsModule.BatchState +type table = { [any]: any } + +export type Proxy = Types.Proxy + +export type LinkGroup = Types.LinkGroup +export type LinkAnchor = Types.LinkAnchor +export type AppliedOp = Types.AppliedOp + +export type DuplicateReferenceMode = Types.DuplicateReferenceMode +export type TableManagerConfig = Types.TableManagerConfig +export type ForOptions = Types.ForOptions +export type TableManager = Types.TableManager +export type Linker = Types.Linker + +export type ExtendConfig = Types.ExtendConfig +export type OpaqueWrapper = Types.OpaqueWrapper + +export type TM_Internal = Types.TM_Internal +type ProxyManager = ProxyManagerModule.ProxyManager + +--// Constants //-- +const DEFAULT_DUPLICATE_REFERENCE_MODE: DuplicateReferenceMode = "error" +const DEFAULT_SIGNAL_FIRE_MODE: Types.SignalFireMode = "bindable" +const DEFAULT_LISTENER_FIRE_MODE: ListenerRegistryModule.FireMode = "bindable" +const DEFAULT_FLUSH_MODE: Types.FlushMode = "immediate" + +-------------------------------------------------------------------------------- +--// Module //-- +-------------------------------------------------------------------------------- + +const TableManager = {} +const TableManager_MT = { __index = TableManager } + +TableManager.T = T + +-- Write-core helpers live in Mutator.luau; local aliases keep call sites unchanged. +const resolvePathFromPathOrProxy = MutatorModule.resolvePathFromPathOrProxy +const resolveArrayForWrite = MutatorModule.resolveArrayForWrite +const getParentOriginalAtPath = MutatorModule.getParentOriginalAtPath +const applyWrite = MutatorModule.applyWrite +const applyRootSet = MutatorModule.applyRootSet +const pruneDetachedValue = MutatorModule.pruneDetachedValue +const shiftArrayKeys = MutatorModule.shiftArrayKeys +const reparentWithRollback = MutatorModule.reparentWithRollback +const restoreReparent = MutatorModule.restoreReparent +const deepCloneValue = MutatorModule.deepCloneValue +const trackArrayMutation = MutatorModule.trackArrayMutation + +-- Emit/notify core lives in Emitter.luau; local aliases keep call sites unchanged. +const fireAncestorValueChangedNotifications = EmitterModule.fireAncestorValueChangedNotifications +const makeEmit = EmitterModule.makeEmit + +-- Shared For/Map reconcile drivers live in ForMap.luau; local aliases keep call sites unchanged. +const makeKeyedReconciler = ForMapModule.makeKeyedReconciler +const makeValueReconciler = ForMapModule.makeValueReconciler + +const createAncestorSnapshot = BatchUtilsModule.CreateAncestorSnapshot + +----------------------------------------------------------------------------------- +--// Constructor //-- +----------------------------------------------------------------------------------- + +-- Validates `self.Raw` against `self._schema` (set just before this +-- is called). Separated from the constructor's straight-line field setup +-- since it has its own branching/error-reporting logic. +const function validateInitialSchema(self: TM_Internal) + if self._schema then + const ok, err = SchemaNavigatorModule.Validate(self._schema, {}, self.Raw) + if not ok then + const message = err or "Schema validation failed at " + if self._onValidationFailed then + self._onValidationFailed({}, self.Raw, message) + end + error(message, 2) + end + end +end + +-- Lowercases `Config.SignalFireMode` so it matches the lowercase literals +-- `Emitter.fireSignal` compares against (`ListenerRegistry.resolveFireMode` +-- already does the same for `ListenerFireMode`) -- without this, a +-- differently-cased value (e.g. "Coalesced") silently fails every comparison +-- in `fireSignal` and falls through to its immediate-fire `else` branch. +const function normalizeSignalFireMode(mode: Types.SignalFireMode?): Types.SignalFireMode + if mode == nil then + return DEFAULT_SIGNAL_FIRE_MODE + end + return (mode :: any):lower() :: Types.SignalFireMode +end + +-- Resolves `Config.EnableProxies` and creates (or nils out) `self._proxyManager`/ +-- `self.Proxy` accordingly, warning on the misconfigured EnableProxies=false + +-- DuplicateReferenceMode combo. +const function initProxyManager(self: TM_Internal, resolvedConfig: TableManagerConfig): ProxyManager? + const enableProxies = resolvedConfig.EnableProxies ~= false + if not enableProxies and resolvedConfig.DuplicateReferenceMode ~= nil then + warn("DuplicateReferenceMode has no effect when Config.EnableProxies = false") + end + + return if enableProxies then ProxyManagerModule.new(self.Raw) else nil +end + +-- Wires the proxy-free write core into `self._proxyManager` (if proxies are +-- enabled) and creates the root proxy. No-op when `EnableProxies = false`. +const function wireProxyWriteHandler(self: TM_Internal): Proxy? + const proxyManager = self._proxyManager + if not proxyManager then return end + -- Single write-integration point: every proxy write resolves its live path + -- and unwrapped value, then flows through the proxy-free write core. + proxyManager:SetWriteHandler(function(path: PathArray, value: any) + applyWrite(self, path, value) + end) + + -- Create root proxy (no parent, no key) + return proxyManager:CreateProxy(self.Raw, nil, nil) +end + +--[=[ + @within TableManager + @function new + + Creates a new TableManager instance. + + @param initialData table -- The initial table data to manage. Must be a table. + @param config TableManagerConfig? -- Optional configuration for the TableManager. + @return TableManager -- The newly created TableManager instance. +]=] +function TableManager.new(initialData: T, config: TableManagerConfig?): TableManager + debug.profilebegin("TM.new") + const self: TM_Internal = (setmetatable({}, TableManager_MT) :: any) :: TM_Internal + const resolvedConfig: TableManagerConfig = config or {} :: { [string]: any? } + const duplicateReferenceMode: DuplicateReferenceMode = resolvedConfig.DuplicateReferenceMode or DEFAULT_DUPLICATE_REFERENCE_MODE + + assert(type(initialData) == "table", "Initial data must be a table") + -- Store original data + self.Raw = initialData + self._schema = resolvedConfig.Schema + self._onValidationFailed = resolvedConfig.OnValidationFailed + self._duplicateReferenceMode = duplicateReferenceMode + + -- Validate initial data against the root schema at construction time. + validateInitialSchema(self) + + -- Batch state (reset at start of each Suspend/Resume cycle) + self._batchDepth = 0 + self._batch = nil + + -- Lazily created on first use by a `Map*` helper (see `attachOwnedCleanup`). + self._janitor = Janitor.new() + + self._opaqueRegistries = OpaqueRegistryModule.NewRegistries() + self._ignoreTrie = IgnoreTrieModule.new() + -- Persistent diff baseline for observed regions (see Shadow.luau / Flush). + self._shadow = ShadowModule.new() + if resolvedConfig.IgnoredPaths then + for _, ignoredPath in resolvedConfig.IgnoredPaths :: { PathHelpers.Path } do + IgnoreTrieModule.SetPathIgnored(self._ignoreTrie, ignoredPath, true) + end + end + + -- Initialize subsystems + self._proxyManager = initProxyManager(self, resolvedConfig) + self._listenerRegistry = ListenerRegistryModule.new { + DebugMode = false, + FireMode = resolvedConfig.ListenerFireMode or DEFAULT_LISTENER_FIRE_MODE, + } + self._signalFireMode = normalizeSignalFireMode(resolvedConfig.SignalFireMode) + self._flushMode = resolvedConfig.FlushMode or DEFAULT_FLUSH_MODE + self._pendingFlushRoot = nil + self._coalescedFlushScheduled = false + self.Proxy = wireProxyWriteHandler(self) + + -- Initialize signals (fire once per change) + self.ValueChanged = Signal.new() :: any + self.KeyAdded = Signal.new() :: any + self.KeyRemoved = Signal.new() :: any + self.KeyChanged = Signal.new() :: any + self.ArrayInserted = Signal.new() :: any + self.ArrayRemoved = Signal.new() :: any + self.ArraySet = Signal.new() :: any + + -- Initialize ChangeDetector with callbacks (signal+listener dual-fire in Emitter). + -- `:: any` bypasses the generic-vs-non-generic callback signature mismatch + -- (same pattern the original inline closures used to avoid). + self._changeDetector = ChangeDetectorModule.new( + EmitterModule.makeChangeDetectorCallbacks(self :: any) :: any, + nil, + self._opaqueRegistries, + self._ignoreTrie + ) + + + -- Public linking facade (lightweight; heavy link state stays lazy). + self.Linker = LinkerModule.new(self) + + -- Opt-in auto-link: form any links implied by the current tree topology + -- (this manager nested inside another, or others nested inside it). + if resolvedConfig.AutoLink then + self._autoLink = true + LinkGroupModule.RegisterAutoLink(self) + end + + debug.profileend() + return self :: any +end + +-------------------------------------------------------------------------------- +--// Linking //-- +-------------------------------------------------------------------------------- + +--[=[ + @within TableManager + @function Link + + Joins TableManagers (or interior nodes within them) that share one + underlying table by identity into a notify-only `LinkGroup`. A write + applied through any member fans out to every other current member, + translated into that member's own path coordinates. See `LinkGroup`. + + Two call forms: + - `TableManager.Link({ {managerA, pathA?}, {managerB, pathB?}, ... })` + - `TableManager.Link(managerA, pathA, managerB, pathB)` +]=] +TableManager.Link = LinkGroupModule.Link + +--[=[ + @private + Re-fires listeners/signals for an op that has ALREADY been applied to the + shared raw (by another linked manager), without mutating or + snapshot-diffing. `op.Path` is in this manager's own coordinates. Used only + by `LinkGroup` fan-out. +]=] +function TableManager._NotifyApplied(self: TM_Internal, op: AppliedOp) + if self._Destroyed then + return + end + + self._suppressLinkFanOut = true + const ok, err = pcall(function() + if op.Kind == "Set" then + pruneDetachedValue(self, op.OldValue, op.NewValue) + const ctx = self._changeDetector:GetOpaqueCtx() + -- Phase A: only dispatch if THIS manager observes the path. When it + -- doesn't, no listener/signal of its own would fire, so the diff is + -- pure waste — skip it. (The shadow is still synced below: this + -- manager can later ORIGINATE a write whose fan-out oldValue reads + -- from its shadow, so the baseline must stay current regardless.) + if CoverageModule.IsLocallyObserved(self, op.Path) then + -- Phase B: reuse the origin's already-computed diff node instead + -- of re-diffing the identical old/new, but only when neither the + -- origin (op.OriginHasNoOpacity) nor this manager has any + -- PER-MANAGER opaque marks. Global opacity is shared, so it shapes + -- both diffs identically and is safe to reuse across; only + -- per-manager marks can diverge the trees. (Checking the registry + -- count, not `ctx == nil`, also keeps reuse alive when some + -- unrelated manager elsewhere has used GlobalOpaque this session.) + if op.Diff ~= nil and op.OriginHasNoOpacity and self._opaqueRegistries.Count == 0 then + self._changeDetector:DispatchDiffNode(op.Diff, op.Path, self.Raw) + else + self._changeDetector:CheckForChangesBetween(op.OldValue, op.NewValue, op.Path, self.Raw) + end + end + -- This bypasses applyWrite entirely (the write already landed via + -- the ORIGIN manager), so the shadow needs its own sync here — + -- applyWrite's ensureShadowSeeded/_doFlush never run for this path. + -- Skipped through an opaque ancestor (see + -- Coverage.PassesThroughOpaqueAncestor) since Materialize can't + -- safely persist there; the dispatch above already fired correctly + -- using op.OldValue/op.NewValue directly, independent of the shadow. + if CoverageModule.IsTrackable(self, op.Path) then + if not CoverageModule.PassesThroughOpaqueAncestor(self, op.Path, ctx) then + self._shadow:Materialize(op.Path, self.Raw, ctx) + end + end + elseif op.Kind == "ArrayInsert" then + const index = op.Index :: number + shiftArrayKeys(self._proxyManager, self:Get(op.Path, true), index, 1) + makeEmit(self, op.Path).inserted(index, op.NewValue) + elseif op.Kind == "ArrayRemove" then + const index = op.Index :: number + shiftArrayKeys(self._proxyManager, self:Get(op.Path, true), index + 1, -1) + pruneDetachedValue(self, op.OldValue, nil) + makeEmit(self, op.Path).removed(index, op.OldValue) + elseif op.Kind == "ArraySet" then + makeEmit(self, op.Path).set(op.Index :: number, op.NewValue, op.OldValue) + end + end) + self._suppressLinkFanOut = false + + if not ok then + error(err, 0) + end +end + +--[=[ + @within TableManager + + Returns a plain `TableManager` rooted at the shared table found at `target`, + linked (via `Link`) to this manager so writes on either side fan out to the + other. `target` may be: + - a proxy obtained from this manager (preferred); + - a raw table value that is already part of this manager's tree; or + - a path (string or array) into this manager's tree. + + The new TM is bound to the targeted table, not its path in the extending TM. +]=] +function TableManager.Extend( + self: TM_Internal, + target: Proxy | Path +): TableManager> + local anchorPath: PathArray + if self._proxyManager and self._proxyManager:IsProxy(target) then + -- Preferred form: a proxy already names its live path. + const path = self._proxyManager:GetPath(target) + if path == nil then + error("Extend target proxy is not part of this manager's tree") + end + anchorPath = path + else + -- Raw value already in the tree: locate it by identity (it may not have + -- a proxy yet if it was never accessed via Get/GetProxy). Falls back to + -- treating `target` as a path (string or array) if not found. + const foundPath = if type(target) == "table" then LinkGroupModule.FindPathOf(self.Raw, target) else nil + if foundPath ~= nil then + anchorPath = foundPath + else + anchorPath = PathHelpers.ParsePath(target) + end + end + + const object = self:Get(anchorPath) + if type(object) ~= "table" then + error("Extend target must resolve to a table") + end + + const extended = TableManager.new(object) + LinkGroupModule.Link({ { self , anchorPath }, { extended } }) + + return extended +end + +-------------------------------------------------------------------------------- +--// Listener Registration //-- +-------------------------------------------------------------------------------- + +--[=[ + Fires ONLY when this exact path is directly reassigned (not when a + descendant of it changes). Shorthand for `OnChange` with + `ListenDepth = 0, ListenDepthStyle = "=="`. +]=] +function TableManager.OnValueChange( + self: TM_Internal, + path: Path, + callback: (newValue: any, oldValue: any?, metadata: ChangeMetadata) -> (), + options: ListenerOptions? +): Connection + const mergedOptions: ListenerOptions = if options then table.clone(options :: any) else {} + mergedOptions.ListenDepth = 0 + mergedOptions.ListenDepthStyle = "==" + return self._listenerRegistry:RegisterListener("ValueChanged", PathHelpers.ParsePath(path), callback, mergedOptions) +end + +--[=[ + Fires when this path is directly reassigned OR any descendant of it + changes. +]=] +function TableManager.OnChange( + self: TM_Internal, + path: Path, + callback: (newValue: any, oldValue: any?, metadata: ChangeMetadata) -> (), + options: ListenerOptions? +): Connection + return self._listenerRegistry:RegisterListener("ValueChanged", PathHelpers.ParsePath(path), callback, options) +end + +--[=[ + Immediately invokes `callback` with the current value at `path` + (`oldValue` and `metadata` both `nil`), then behaves like + `OnValueChange` for subsequent changes. +]=] +function TableManager.Observe( + self: TM_Internal, + path: Path, + callback: (newValue: any, oldValue: any?, metadata: ChangeMetadata?) -> (), + options: ListenerOptions? +): Connection + const parsedPath = PathHelpers.ParsePath(path) + const currentValue = self:Get(parsedPath, true) + + const function fireInitial() + callback(currentValue, nil, nil) + end + + if self._listenerRegistry._fireMode ~= "immediate" then + task.defer(fireInitial) + else + task.spawn(fireInitial) + end + + return self._listenerRegistry:RegisterListener("ValueChanged", parsedPath, callback, options) +end + +function TableManager.OnKeyAdd( + self: TM_Internal, + path: Path, + callback: (newValue: any, metadata: ChangeMetadata) -> (), + options: ListenerOptions? +): Connection + return self._listenerRegistry:RegisterListener("KeyAdded", PathHelpers.ParsePath(path), callback, options) +end + +function TableManager.OnKeyRemove( + self: TM_Internal, + path: Path, + callback: (oldValue: any, metadata: ChangeMetadata) -> (), + options: ListenerOptions? +): Connection + return self._listenerRegistry:RegisterListener("KeyRemoved", PathHelpers.ParsePath(path), callback, options) +end + +function TableManager.OnKeyChange( + self: TM_Internal, + path: Path, + callback: (key: any, newValue: any, oldValue: any, metadata: ChangeMetadata) -> (), + options: ListenerOptions? +): Connection + return self._listenerRegistry:RegisterListener("KeyChanged", PathHelpers.ParsePath(path), callback, options) +end + +function TableManager.OnArrayInsert( + self: TM_Internal, + path: Path, + callback: (index: number, newValue: any, metadata: ChangeMetadata) -> (), + options: ListenerOptions? +): Connection + return self._listenerRegistry:RegisterListener("ArrayInserted", PathHelpers.ParsePath(path), callback, options) +end + +function TableManager.OnArrayRemove( + self: TM_Internal, + path: Path, + callback: (index: number, oldValue: any, metadata: ChangeMetadata) -> (), + options: ListenerOptions? +): Connection + return self._listenerRegistry:RegisterListener("ArrayRemoved", PathHelpers.ParsePath(path), callback, options) +end + +function TableManager.OnArraySet( + self: TM_Internal, + path: Path, + callback: ( + index: number, + newValue: any, + oldValue: any, + metadata: ChangeMetadata + ) -> (), + options: ListenerOptions? +): Connection + return self._listenerRegistry:RegisterListener("ArraySet", PathHelpers.ParsePath(path), callback, options) +end + +-------------------------------------------------------------------------------- +--// For / Map //-- +-------------------------------------------------------------------------------- +-- Thin orchestration methods. The shared identity-based reconcile drivers +-- (`makeKeyedReconciler`/`makeValueReconciler`/`attachOwnedCleanup`) live in +-- ForMap.luau and are aliased above; `MapKeys`/`MapValues`/`MapPairs` stay +-- here because they call `TableManager.new` directly to create the derived +-- manager, which ForMap.luau cannot do without a circular require. + +--[=[ + Reconciles the items found at `path` by KEY: `handler` runs once per key + (passed a per-item Janitor, the key, and change metadata — `nil` on the + initial fire), and is NOT re-run when the value at an existing key + changes. The Janitor is destroyed when that key is removed or the + connection disconnects. +]=] +function TableManager.ForKeys( + self: TM_Internal, + path: Path, + handler: (itemJanitor: Janitor, key: KeyAtPath, metadata: ChangeMetadata?) -> (), + options: ForOptions? +): Connection + const parsedPath = PathHelpers.ParsePath(path) + return makeKeyedReconciler(self, parsedPath, false, function(itemJanitor, key, _value, metadata) + handler(itemJanitor, key, metadata) + end, nil, nil, options, nil) +end + +--[=[ + Reconciles the items found at `path` by VALUE (as a multiset — duplicate + values are tracked as independent items): `handler` runs once per value + occurrence and is NOT re-run when that value moves between keys/indices + (e.g. an `ArraySwapRemove`). The Janitor is destroyed when that value + occurrence is no longer present or the connection disconnects. +]=] +function TableManager.ForValues( + self: TM_Internal, + path: Path, + handler: (itemJanitor: Janitor, value: ElementAtPath, metadata: ChangeMetadata?) -> (), + options: ForOptions? +): Connection + const parsedPath = PathHelpers.ParsePath(path) + return makeValueReconciler(self, parsedPath, handler, options) +end + +--[=[ + Reconciles the items found at `path` by KEY AND VALUE: `handler` re-runs + (tearing down the previous item's Janitor first) whenever EITHER the key + or the value changes — a value change at an existing key is treated as + that item leaving and a new one appearing. +]=] +function TableManager.ForPairs( + self: TM_Internal, + path: Path, + handler: ( + itemJanitor: Janitor, + key: KeyAtPath, + value: ElementAtPath, + metadata: ChangeMetadata? + ) -> (), + options: ForOptions? +): Connection + const parsedPath = PathHelpers.ParsePath(path) + return makeKeyedReconciler(self, parsedPath, true, handler, nil, nil, options, nil) +end + +--[=[ + Returns a live `TableManager` keyed by `transform(entryJanitor, key, value)`, + with values passed through unchanged. `transform` re-runs only when the + source key is added/removed; a value change at an existing key refreshes + the output value WITHOUT re-running `transform`. The output manager owns + the source subscription: destroying it disconnects from the source and + tears down every entry's Janitor. Destroying the SOURCE does not + cascade-destroy the returned manager (independent lifecycles). +]=] +function TableManager.MapKeys( + self: TM_Internal, + path: Path, + transform: (entryJanitor: Janitor, key: KeyAtPath, value: ElementAtPath) -> any +): TableManager + const parsedPath = PathHelpers.ParsePath(path) + const derived: TM_Internal = TableManager.new({}) :: any + const outKeys: { [any]: any } = {} + + const connection = makeKeyedReconciler( + self, + parsedPath, + false, + function(itemJanitor, key, value) + const outKey = transform(itemJanitor, key, value) + outKeys[key] = outKey + derived:Set(outKey, value) + end, + function(_item, key) + const outKey = outKeys[key] + outKeys[key] = nil + if outKey ~= nil then + derived:Set(outKey, nil) + end + end, + function(_item, key, newValue) + const outKey = outKeys[key] + if outKey ~= nil then + derived:Set(outKey, newValue) + end + end, + nil, + function(fire) + derived:Batch(fire) + end + ) + derived._janitor:Add(connection, "Disconnect") + + return derived :: any +end + +--[=[ + Returns a live `TableManager` keyed by the SOURCE key, with each entry + recomputed via `transform(entryJanitor, value, key)` whenever its value + changes. Same ownership/lifecycle rules as `MapKeys`. +]=] +function TableManager.MapValues( + self: TM_Internal, + path: Path, + transform: (entryJanitor: Janitor, value: ElementAtPath, key: KeyAtPath) -> any +): TableManager + const parsedPath = PathHelpers.ParsePath(path) + const derived: TM_Internal = TableManager.new({}) :: any + + const connection = makeKeyedReconciler( + self, + parsedPath, + true, + function(itemJanitor, key, value) + derived:Set(key, transform(itemJanitor, value, key)) + end, + function(_item, key) + derived:Set(key, nil) + end, + nil, + nil, + function(fire) + derived:Batch(fire) + end + ) + derived._janitor:Add(connection, "Disconnect") + + return derived :: any +end + +--[=[ + Returns a live `TableManager` keyed by `transform(entryJanitor, key, value)`'s + first return value (the second is the output value); the entry is + recomputed whenever the source key or value changes. On an output-key + collision between two source entries, the last write wins. Same + ownership/lifecycle rules as `MapKeys`. +]=] +function TableManager.MapPairs( + self: TM_Internal, + path: Path, + transform: (entryJanitor: Janitor, key: KeyAtPath, value: ElementAtPath) -> (any, any) +): TableManager + const parsedPath = PathHelpers.ParsePath(path) + const derived: TM_Internal = TableManager.new({}) :: any + const outKeys: { [any]: any } = {} + + const connection = makeKeyedReconciler( + self, + parsedPath, + true, + function(itemJanitor, key, value) + const outKey, outValue = transform(itemJanitor, key, value) + outKeys[key] = outKey + derived:Set(outKey, outValue) + end, + function(_item, key) + const outKey = outKeys[key] + outKeys[key] = nil + if outKey ~= nil then + derived:Set(outKey, nil) + end + end, + nil, + nil, + function(fire) + derived:Batch(fire) + end + ) + derived._janitor:Add(connection, "Disconnect") + + return derived :: any +end + +-------------------------------------------------------------------------------- +--// Helper Methods //-- +-------------------------------------------------------------------------------- + +--[=[ + Get the value at a path. +]=] +function TableManager.Get(self: TM_Internal, path: Path, suppressNilPartialPaths: boolean?): ValueAtPath? + debug.profilebegin("TM.Get") + const parsedPath = PathHelpers.ParsePath(path) + local current = self.Raw :: T & table + for _, key in parsedPath do + if type(current) ~= "table" then + if suppressNilPartialPaths then + return nil + else + error(`Path segment {key} is not a table`) + end + end + current = current[key] + end + debug.profileend() + return current +end + +--[=[ + Gets a Proxy for the table at the given path, or the raw value if the path + doesn't point to a table. +]=] +function TableManager.GetProxy( + self: TM_Internal, + path: Path, + suppressNilPartialPaths: boolean? +): (Proxy | ValueAtPath)? + debug.profilebegin("TableManager.GetProxy") + const proxyManager = self._proxyManager + if proxyManager == nil then + error("GetProxy requires proxies (Config.EnableProxies = false for this TableManager)") + end + const parsedPath = PathHelpers.ParsePath(path) + local current = self.Proxy + local previousKey: any = nil + for _, key in parsedPath do + if not proxyManager:IsProxy(current) then + if suppressNilPartialPaths then + return nil + else + error(`Path segment {tostring(previousKey)} is not a table`) + end + end + current = current[key] + previousKey = key + end + debug.profileend() + return current +end + +--[=[ + Set the value at a path. + + An empty path (`Set({}, newTable)` / `Set("", newTable)`) replaces the entire + root table: its identity is swapped to `newTable`, stale proxies of the old + tree are pruned, and root/child change listeners fire for the diff. The new + root must be a table (the root cannot become a scalar or `nil`), and the root + cannot be replaced while a batch is open. +]=] +function TableManager.Set( + self: TM_Internal, + path: Path, + value: ValueAtPath | Types.OpaqueWrapper, + buildTablesDynamically: boolean? +) + debug.profilebegin("TM.Set") + local parsedPath = PathHelpers.ParsePath(path) + local unwrappedValue = ProxyManagerModule.Unwrap(value) + unwrappedValue = OpaqueRegistryModule.UnwrapAndRegister(self._opaqueRegistries, unwrappedValue) + + -- Empty path replaces the entire root table (identity swap + full diff). + if #parsedPath == 0 then + applyRootSet(self, unwrappedValue) + debug.profileend() + return + end + + -- Only build intermediate tables when setting non-nil values; removing (nil) doesn't need them + const shouldBuild = buildTablesDynamically and unwrappedValue ~= nil + + local parent: any = self.Raw + for i = 1, #parsedPath - 1 do + local nextValue = parent[parsedPath[i]] + if type(nextValue) ~= "table" then + if not shouldBuild then + if unwrappedValue == nil then + return -- Silently return if we are setting a non existent path to nil since it is already nil. + end + error(`Path segment {parsedPath[i]} is not a table`) + end + nextValue = {} + parent[parsedPath[i]] = nextValue + end + parent = nextValue + end + + applyWrite(self, parsedPath, unwrappedValue) + debug.profileend() +end + +--[=[ + Insert value(s) into an array at a specific position or at the end. +]=] +function TableManager.ArrayInsert(self: TM_Internal, pathOrProxy: Path | Proxy, ...: any): () + debug.profilebegin("TM.ArrayInsert") + local parsedPath, array = resolveArrayForWrite(self, pathOrProxy) + + -- Determine if a position was provided or default to appending. + local pos: number + local newValue: any + if select("#", ...) >= 2 then + local secondArg + pos, secondArg = ... + newValue = secondArg + else + pos = #array + 1 + newValue = ... + end + + local unwrappedValue = ProxyManagerModule.Unwrap(newValue) + unwrappedValue = OpaqueRegistryModule.UnwrapAndRegister(self._opaqueRegistries, unwrappedValue) + + if not MutatorModule.validateArrayInsert(self, parsedPath, unwrappedValue) then return end + + const inBatch = trackArrayMutation(self, parsedPath) + + -- Insert the value (handles shifting when inserting into the middle). + table.insert(array, pos, unwrappedValue) + -- Update Key metadata for all proxies that were shifted right by this insert. + shiftArrayKeys(self._proxyManager, array, pos, 1) + -- Direct auto-link for an inserted managed table. + if self._autoLink and not self._suppressLinkFanOut then + const elementPath = PathHelpers.Append(parsedPath, pos) + LinkGroupModule.CheckAutoLink(self :: any, elementPath, unwrappedValue) + end + + if not inBatch then + -- Fire element-level and array-level listeners, ancestor callbacks, and signal. + makeEmit(self, parsedPath).inserted(pos, unwrappedValue) + end + debug.profileend() +end +TableManager.Insert = TableManager.ArrayInsert + +--[=[ + Remove an element from an array at a specific index. +]=] +function TableManager.ArrayRemove(self: TM_Internal, pathOrProxy: Path | Proxy, index: number): any + debug.profilebegin("TM.ArrayRemove") + local parsedPath, array = resolveArrayForWrite(self, pathOrProxy) + + const inBatch = trackArrayMutation(self, parsedPath) + + -- Remove the element (handles shifting automatically). + const oldValue = table.remove(array, index) + -- Shift proxies after the removed slot left by 1. Pass index+1 as fromIndex + -- so the removed item's own proxy (if held) keeps its original key rather than + -- being shifted to key=0. + shiftArrayKeys(self._proxyManager, array, index + 1, -1) + pruneDetachedValue(self, oldValue, nil) + if not self._suppressLinkFanOut then + LinkGroupModule.CheckDivergence(self, parsedPath) + end + + if inBatch then return oldValue end + + -- Fire listeners EXACTLY at remove path (we handle ancestors separately) + makeEmit(self, parsedPath).removed(index, oldValue) + + return oldValue +end +TableManager.Remove = TableManager.ArrayRemove + +--[=[ + Remove the first matching value from an array. + Returns the index it was removed from, or nil if not found. +]=] +function TableManager.ArrayRemoveFirstValue(self: TM_Internal, pathOrProxy: Path, valueToFind: any): number? + local parsedPath, array = resolveArrayForWrite(self, pathOrProxy) + + const index = table.find(array, valueToFind) + if index == nil then + return nil + end + + self:ArrayRemove(parsedPath, index) + return index +end +TableManager.RemoveFirstValue = TableManager.ArrayRemoveFirstValue + +--[=[ + Remove an array element in O(1) by swapping in the last element. + Order is not preserved. +]=] +function TableManager.ArraySwapRemove(self: TM_Internal, pathOrProxy: Path | Proxy, index: number): any + local parsedPath, array = resolveArrayForWrite(self, pathOrProxy) + + const lastIndex = #array + if index < 1 or index > lastIndex then + return nil + end + + const oldValue = array[index] + const movedValue = array[lastIndex] + + const inBatch = trackArrayMutation(self, parsedPath) + + if index ~= lastIndex then + array[index] = movedValue + end + array[lastIndex] = nil + pruneDetachedValue(self, oldValue, nil) + if not self._suppressLinkFanOut then + LinkGroupModule.CheckDivergence(self, parsedPath) + end + + if inBatch then return oldValue end + + const moveInfo = if index ~= lastIndex + then { moveId = `swapremove_{index}_{lastIndex}`, fromIndex = lastIndex, toIndex = index } + else nil + + const emit = makeEmit(self, parsedPath) + + -- 1. Emit ArraySet for the backfill: array[index] = movedValue (no-op if index == lastIndex) + if index ~= lastIndex then + emit.set(index, movedValue, oldValue, moveInfo) + end + + -- 2. Emit ArrayRemoved for the shrink: array[lastIndex] = nil (the actual removal) + emit.removed(lastIndex, movedValue, moveInfo) + + return oldValue +end +TableManager.SwapRemove = TableManager.ArraySwapRemove + +--[=[ + Find and swap-remove the first matching value in an array. + Returns the removed index, or nil if not found. +]=] +function TableManager.ArraySwapRemoveFirstValue( + self: TM_Internal, + pathOrProxy: Path | Proxy, + valueToFind: any +): number? + local parsedPath, array = resolveArrayForWrite(self, pathOrProxy) + + const index = table.find(array, valueToFind) + if index == nil then + return nil + end + + self:ArraySwapRemove(parsedPath, index) + return index +end +TableManager.SwapRemoveFirstValue = TableManager.ArraySwapRemoveFirstValue + +--[=[ + Holds off firing signals for the duration of the callback, then fires needed signals at the end. + Useful for batch operations where you want to suppress intermediate signals and only fire final results. + + Nested calls are no-ops: the outermost Batch window covers everything. + + :::caution yielding + Yielding within a batch window will leave the TableManager in a suspended state, which + can cause unexpected behavior. + ::: +]=] +function TableManager.Batch(self: TM_Internal, fn: () -> ()) + if self._batchDepth > 0 then + -- Already inside a batch window; run the callback in-place + fn() + return + end + self:Suspend() + const ok, err = (pcall :: any)(fn) + self:Resume() + if not ok then + error(err, 2) + end +end + +--[=[ + Suspends all signal and listener firing. + + It is recommended to use `:Batch` for better ergonomics and safety, + but `Suspend`/`Resume` can be used for more manual control if needed. + + Pair with `Resume()`. Nested calls are no-ops (the outermost window wins). +]=] +function TableManager.Suspend(self: TM_Internal) + BatchFlushModule.Suspend(self) +end + +--[=[ + Resumes after `Suspend()` and flushes all pending changes. + + Flush is two-phase: + 1. **Non-array flush** — per dirty branch, `CheckForChangesBetween` diffs the + pre-batch value against the current value (with tracked-array subtrees + masked out — the array flush owns those), firing all non-array events. + 2. **Array flush** — For each tracked array path, diffs the pre-batch + snapshot against the current value via LCS (`ArrayDiff.emitDiff`). +]=] +function TableManager.Resume(self: TM_Internal) + BatchFlushModule.Resume(self) +end + +--[=[ + @private + Internal, unconditional diff+fire+reconcile for `path`: diffs the + persistent shadow's last-flushed value against the current live value, + fires the resulting events via `CheckForChangesBetween`, then reconciles + the shadow to live. Callers (the immediate write path, `Flush`, + `_NotifyApplied`, `BatchFlush.Resume`) are responsible for the coverage + gate and batch-state check; this always runs. +]=] +function TableManager._doFlush(self: TM_Internal, path: PathArray) + const ctx = self._changeDetector:GetOpaqueCtx() + const old = self._shadow:Get(path) + const new_ = self:Get(path :: any, true) + self._changeDetector:CheckForChangesBetween(old, new_, path, self.Raw) + self._shadow:Reconcile(path, self.Raw, ctx) +end + +--[=[ + Diffs the persistent shadow baseline against the live value at `path` + (the whole tree when omitted), fires any resulting events, then + reconciles the shadow to the current live value. A no-op when nothing + observes `path` (no registered listener covers it, no global Signal is + connected, and no link group is active). + + Outside a batch this runs immediately. Inside a `Suspend`/`Batch` window + it just marks the branch dirty for `Resume`'s flush instead of firing, so + an explicit `Flush` can never break batch atomicity. + + This is also the supported way to surface a mutation made by code that + bypassed `Set`/`Proxy` and wrote directly into the underlying table. +]=] +function TableManager.Flush(self: TM_Internal, path: Path?) + const parsedPath: PathArray = if path ~= nil then PathHelpers.ParsePath(path) else {} + if not CoverageModule.IsTrackable(self, parsedPath) then + return + end + if self._batchDepth > 0 then + const batch = self._batch + if batch then + BatchUtilsModule.MarkBatchBranchDirty(batch, parsedPath) + end + return + end + -- An explicit Flush is always synchronous regardless of FlushMode (same + -- manual-control contract as Suspend/Resume). Drain any outstanding + -- coalesced flush first so this synchronous diff reads an authoritative + -- shadow baseline instead of racing the deferred one. + CoalescedFlushModule.FlushPending(self) + self:_doFlush(parsedPath) +end + +--[=[ + Marks `path` (and every descendant of it) as ignored or not. An ignored + write still happens — `Get`/`Raw` reflect it immediately — but skips all + diff/snapshot/event work, so it never fires listeners/signals and is never + cloned for change detection. See `TableManagerConfig.IgnoredPaths` for the + config-time equivalent (seeded once at construction). +]=] +function TableManager.SetPathIgnored(self: TM_Internal, path: Path, ignored: boolean) + IgnoreTrieModule.SetPathIgnored(self._ignoreTrie, path, ignored) +end + +--[=[ + Move an element from one location to another within the same table. + This unsets the value at the current path and sets it at the new path, firing appropriate notifications. + This is specifically useful for moving tables around without breaking proxy references. +]=] +function TableManager.MoveTo( + self: TM_Internal, + currentPath: Path | Proxy, + newPath: Path | Proxy +) + debug.profilebegin("TM.MoveTo") + const sourcePath: PathArray = resolvePathFromPathOrProxy(self, currentPath) + const targetPath: PathArray = resolvePathFromPathOrProxy(self, newPath) + + if #sourcePath == 0 or #targetPath == 0 then + error("MoveTo cannot move the root table") + end + + if PathHelpers.ArePathsEqual(sourcePath, targetPath) then + debug.profileend() + return + end + + if PathHelpers.IsPrefixPath(sourcePath, targetPath) then + error("MoveTo cannot move a table into one of its descendants") + end + + const sourceValue = self:Get(sourcePath :: Path) + if type(sourceValue) ~= "table" then + error("MoveTo source must be a table") + end + + const targetParentPath, targetKey = PathHelpers.GetPathParentAndKey(targetPath) + const targetParentOriginal = getParentOriginalAtPath(self, targetParentPath, "MoveTo") + + const rollback = reparentWithRollback(self._proxyManager, sourceValue, targetParentOriginal, targetKey) + + const ok, moveErr = pcall(function() + self:Batch(function() + self:Set(targetPath, sourceValue) + self:Set(sourcePath, nil :: any) + end) + end) + + if not ok then + restoreReparent(self._proxyManager, rollback) + error(moveErr, 2) + end + debug.profileend() +end + +--[=[ + DeepCopy a value from one location to another within the same table. + This sets the value at the new path to be the same as the value at the current path, firing appropriate notifications. + This is specifically useful for copying tables around without breaking proxy references. +]=] +function TableManager.CopyTo(self: TM_Internal, currentPath: Path | Proxy, newPath: Path) + debug.profilebegin("TM.CopyTo") + const sourcePath = resolvePathFromPathOrProxy(self, currentPath) + const targetPath = resolvePathFromPathOrProxy(self, newPath) + + if #targetPath == 0 then + error("CopyTo cannot set the root table") + end + + if PathHelpers.ArePathsEqual(sourcePath, targetPath) then + return + end + + -- Validate destination parent before any mutation. + const targetParentPath: { any } = table.clone(targetPath :: any) + table.remove(targetParentPath) + getParentOriginalAtPath(self, targetParentPath, "CopyTo") + + const sourceValue = self:Get(sourcePath) + const copiedValue = deepCloneValue(sourceValue) + self:Set(targetPath, copiedValue) + debug.profileend() +end + +--[=[ + Swap values at two paths within the same TM. +]=] +function TableManager.Swap(self: TM_Internal, a: Path | Proxy, b: Path | Proxy) + const pathA = resolvePathFromPathOrProxy(self, a) + const pathB = resolvePathFromPathOrProxy(self, b) + + if #pathA == 0 or #pathB == 0 then + error("Swap cannot target the root table") + end + + if PathHelpers.ArePathsEqual(pathA, pathB) then + return + end + + if PathHelpers.IsPrefixPath(pathA, pathB) or PathHelpers.IsPrefixPath(pathB, pathA) then + error("Swap cannot swap ancestor/descendant paths") + end + + const parentPathA, keyA = PathHelpers.GetPathParentAndKey(pathA) + const parentPathB, keyB = PathHelpers.GetPathParentAndKey(pathB) + const parentOriginalA = getParentOriginalAtPath(self, parentPathA, "Swap") + const parentOriginalB = getParentOriginalAtPath(self, parentPathB, "Swap") + + const valueA = self:Get(pathA) + const valueB = self:Get(pathB) + + const rollbackA = reparentWithRollback(self._proxyManager, valueA, parentOriginalB, keyB) + const rollbackB = reparentWithRollback(self._proxyManager, valueB, parentOriginalA, keyA) + + const ok, swapErr = pcall(function() + self:Batch(function() + self:Set(pathA, valueB) + self:Set(pathB, valueA) + end) + end) + + if not ok then + restoreReparent(self._proxyManager, rollbackA) + restoreReparent(self._proxyManager, rollbackB) + error(swapErr, 2) + end +end + +-------------------------------------------------------------------------------- +--// Other Utility Methods //-- +-------------------------------------------------------------------------------- + +--[=[ + @private + @unreleased + Force notification for a specific path, even if the value hasn't changed. + + This is useful for scenarios like: + - Re-assigning the same table (e.g., `Data.Stats = sameStats`) + - Forcing validation/refresh logic to run + - Implementing "force refresh" patterns + + Example: + ```lua + local sameStats = manager.Proxy.players.John.Stats + manager.Proxy.players.John.Stats = sameStats -- Normally no listeners fire + manager:ForceNotify({"players", "John", "Stats"}) -- Force listeners to fire + ``` + + Note: This creates a synthetic "changed" diff where old == new. + Your listeners can detect this by checking if `metadata.Diff.old == metadata.Diff.new`. +]=] +function TableManager.ForceNotify(self: TM_Internal, path: Path) + if self._batchDepth > 0 then + return -- Suppress during batch; flush will re-emit all changes + end + const parsedPath = PathHelpers.ParsePath(path) + const currentValue = self:Get(parsedPath) + + -- Create synthetic metadata for a "changed" event where old == new + const metadata: ChangeMetadata = { + Diff = { + type = "changed", + old = currentValue, + new = currentValue, + children = nil, + }, + OriginPath = parsedPath, + OriginDiff = { + type = "changed", + old = currentValue, + new = currentValue, + children = nil, + }, + Snapshot = createAncestorSnapshot(self.Raw), + } + + -- Fire ValueChanged at exact path + self._listenerRegistry:FireListenersExact("ValueChanged", parsedPath, { + NewValue = currentValue, + OldValue = currentValue, + Metadata = metadata, + }) + + -- Fire signal + self.ValueChanged:Fire(parsedPath, currentValue, currentValue) + + -- Fire ancestor ValueChanged callbacks from parent path up to root. + if #parsedPath > 0 then + const parentPath = (PathHelpers.GetPathParentAndKey(parsedPath)) + fireAncestorValueChangedNotifications(self, parentPath, metadata, false) + end +end + +--[=[ + Destroy the TableManager and clean up all resources. +]=] +function TableManager.Destroy(self: TM_Internal) + if self._Destroyed then + return + end + self._Destroyed = true + + -- Tear down anything this manager owns (e.g. a `Map*` derived manager's + -- subscription to its source) BEFORE dismantling our own subsystems below, + -- since that teardown writes through `self:Set` to clear output entries. + self._janitor:Destroy() + + self.Linker:Unlink() + LinkGroupModule.UnregisterAutoLink(self) + + -- If destroyed mid-batch (Suspend without Resume), release the batch state. + const batch = self._batch + if batch then + self._batch = nil + self._batchDepth = 0 + end + + self._listenerRegistry:Destroy() + const proxyManager = self._proxyManager + if proxyManager then + proxyManager:Destroy() + end + + -- Disconnect all signals + self.ValueChanged:Destroy() + self.KeyAdded:Destroy() + self.KeyRemoved:Destroy() + self.KeyChanged:Destroy() + self.ArrayInserted:Destroy() + self.ArrayRemoved:Destroy() + self.ArraySet:Destroy() +end + +-- Opacity wrapper constructors (see OpaqueRegistry): mark a value (or a +-- container's direct children) opaque to the diff engine — no clone/freeze/ +-- walk of its internals, only identity compares. `Global*` variants register +-- in a registry shared by every TableManager rather than just this one. +TableManager.Opaque = OpaqueRegistryModule.Opaque +TableManager.GlobalOpaque = OpaqueRegistryModule.GlobalOpaque +TableManager.OpaqueChildren = OpaqueRegistryModule.OpaqueChildren +TableManager.GlobalOpaqueChildren = OpaqueRegistryModule.GlobalOpaqueChildren + +return TableManager diff --git a/lib/tablemanager2/src/Tests/ArrayDiff.spec.luau b/lib/tablemanager2/src/Tests/ArrayDiff.spec.luau new file mode 100644 index 00000000..93f3c984 --- /dev/null +++ b/lib/tablemanager2/src/Tests/ArrayDiff.spec.luau @@ -0,0 +1,257 @@ +--!strict +--[[ + Tests for ArrayDiff.emitDiff — verifies that the LCS-based differ emits + replay-faithful events for all combinations of insertions, removals, and sets. +]] + +return function(t: tiniest) + local ArrayDiff = require("../ArrayDiff") + + local test = t.test + local describe = t.describe + local expect = t.expect + + -- Helper: run emitDiff and collect events as a list of tagged records. + local function diff(old: { any }, new: { any }, setMode: boolean?) + local events: { { kind: string, index: number, value: any, oldValue: any? } } = {} + local emit: ArrayDiff.Emit = { + removed = function(i, ov) + table.insert(events, { kind = "remove", index = i, value = ov, oldValue = nil }) + end, + inserted = function(i, nv) + table.insert(events, { kind = "insert", index = i, value = nv, oldValue = nil }) + end, + set = function(i, nv, ov) + table.insert(events, { kind = "set", index = i, value = nv, oldValue = ov }) + end, + } + ArrayDiff.emitDiff(old, new, emit, if setMode == nil then true else setMode) + return events + end + + -- Helper: apply a list of events to a copy of `old` and return the result. + -- This verifies replay-faithfulness. + local function replay(old: { any }, events: { any }): { any } + local result: { any } = table.create(#old) + for i = 1, #old do + result[i] = old[i] + end + for _, ev in events do + if ev.kind == "remove" then + table.remove(result, ev.index) + elseif ev.kind == "insert" then + table.insert(result, ev.index, ev.value) + elseif ev.kind == "set" then + result[ev.index] = ev.value + end + end + return result + end + + local function arrayEq(a: { any }, b: { any }): boolean + if #a ~= #b then + return false + end + for i = 1, #a do + if a[i] ~= b[i] then + return false + end + end + return true + end + + describe("no-op", function() + test("identical arrays emit nothing", function() + local events = diff({ 1, 2, 3 }, { 1, 2, 3 }) + expect(#events).is(0) + end) + + test("both empty emits nothing", function() + local events = diff({}, {}) + expect(#events).is(0) + end) + end) + + describe("pure insertions", function() + test("append one element", function() + local old = { 1, 2 } + local new = { 1, 2, 3 } + local events = diff(old, new) + expect(#events).is(1) + expect(events[1].kind).is("insert") + expect(events[1].index).is(3) + expect(events[1].value).is(3) + expect(arrayEq(replay(old, events), new)).is_true() + end) + + test("prepend one element", function() + local old = { 2, 3 } + local new = { 1, 2, 3 } + local events = diff(old, new) + expect(#events).is(1) + expect(events[1].kind).is("insert") + expect(events[1].index).is(1) + expect(events[1].value).is(1) + expect(arrayEq(replay(old, events), new)).is_true() + end) + + test("insert in the middle", function() + local old = { 1, 3 } + local new = { 1, 2, 3 } + local events = diff(old, new) + expect(#events).is(1) + expect(events[1].kind).is("insert") + expect(events[1].index).is(2) + expect(arrayEq(replay(old, events), new)).is_true() + end) + + test("insert into empty array", function() + local old: { any } = {} + local new = { "a", "b" } + local events = diff(old, new) + expect(#events).is(2) + expect(arrayEq(replay(old, events), new)).is_true() + end) + end) + + describe("pure removals", function() + test("remove last element", function() + local old = { 1, 2, 3 } + local new = { 1, 2 } + local events = diff(old, new) + expect(#events).is(1) + expect(events[1].kind).is("remove") + expect(events[1].value).is(3) + expect(arrayEq(replay(old, events), new)).is_true() + end) + + test("remove first element — cursor does not advance", function() + local old = { 1, 2, 3 } + local new = { 2, 3 } + local events = diff(old, new) + expect(#events).is(1) + expect(events[1].kind).is("remove") + expect(events[1].index).is(1) + expect(events[1].value).is(1) + expect(arrayEq(replay(old, events), new)).is_true() + end) + + test("two consecutive front removals both report index 1", function() + local old = { "a", "b", "c" } + local new = { "c" } + local events = diff(old, new) + -- Both removals should land at index 1 (replay-faithful: remove(1) remove(1)) + local removes = {} + for _, ev in events do + if ev.kind == "remove" then + table.insert(removes, ev) + end + end + expect(#removes).is(2) + expect(removes[1].index).is(1) + expect(removes[2].index).is(1) + expect(arrayEq(replay(old, events), new)).is_true() + end) + + test("remove all elements", function() + local old = { 10, 20, 30 } + local new: { any } = {} + local events = diff(old, new) + expect(#events).is(3) + expect(arrayEq(replay(old, events), new)).is_true() + end) + end) + + describe("sets (setMode = true)", function() + test("single in-place replacement fuses into set", function() + local old = { 1, 2, 3 } + local new = { 1, 99, 3 } + local events = diff(old, new, true) + expect(#events).is(1) + expect(events[1].kind).is("set") + expect(events[1].index).is(2) + expect(events[1].value).is(99) + expect(events[1].oldValue).is(2) + end) + + test("same-value replacement does NOT fuse into set (values equal)", function() + -- remove+insert where old == new should NOT fuse (values are the same LCS) + -- In practice the LCS diff would keep those elements, so no event at all. + local old = { 1, 2, 3 } + local new = { 1, 2, 3 } + local events = diff(old, new, true) + expect(#events).is(0) + end) + + test("setMode=false emits remove+insert instead of set", function() + local old = { 1, 2, 3 } + local new = { 1, 99, 3 } + local events = diff(old, new, false) + -- Should be remove then insert, not a set + expect(#events).is(2) + local hasRemove = false + local hasInsert = false + for _, ev in events do + if ev.kind == "remove" then + hasRemove = true + end + if ev.kind == "insert" then + hasInsert = true + end + end + expect(hasRemove).is_true() + expect(hasInsert).is_true() + end) + end) + + describe("mixed operations", function() + test("insert and remove at different positions", function() + local old = { "a", "b", "c" } + local new = { "x", "b", "c", "y" } + local events = diff(old, new) + expect(arrayEq(replay(old, events), new)).is_true() + end) + + test("reorder elements (remove + insert)", function() + local old = { 1, 2, 3 } + local new = { 3, 1, 2 } + local events = diff(old, new) + expect(arrayEq(replay(old, events), new)).is_true() + end) + + test("interleaved inserts and removes", function() + local old = { "a", "b", "c", "d" } + local new = { "b", "x", "d", "y" } + local events = diff(old, new) + expect(arrayEq(replay(old, events), new)).is_true() + end) + + test("completely different arrays", function() + local old = { 1, 2, 3 } + local new = { 4, 5, 6 } + local events = diff(old, new) + expect(arrayEq(replay(old, events), new)).is_true() + end) + end) + + describe("replay faithfulness", function() + -- These tests verify that applying events to `old` in order always yields `new`. + local cases: { { old: { any }, new: { any } } } = { + { old = {}, new = { 1 } }, + { old = { 1 }, new = {} }, + { old = { 1, 2, 3 }, new = { 1, 3 } }, + { old = { 1, 3 }, new = { 1, 2, 3 } }, + { old = { "a", "b", "c" }, new = { "c", "b", "a" } }, + { old = { 1, 2, 3, 4, 5 }, new = { 2, 4, 6 } }, + } + + for _, case in cases do + local oldStr = table.concat(case.old :: { string }, ",") + local newStr = table.concat(case.new :: { string }, ",") + test(`replay {oldStr} -> {newStr}`, function() + local events = diff(case.old, case.new) + expect(arrayEq(replay(case.old, events), case.new)).is_true() + end) + end + end) +end diff --git a/lib/tablemanager2/src/Tests/ChangeDetector.spec.luau b/lib/tablemanager2/src/Tests/ChangeDetector.spec.luau new file mode 100644 index 00000000..375879ab --- /dev/null +++ b/lib/tablemanager2/src/Tests/ChangeDetector.spec.luau @@ -0,0 +1,1546 @@ +--!strict +--[[ + IMPORTANT: Callback Classification + + ChangeDetector uses metadata.Diff to distinguish between callback types: + + - **Leaf Callback**: A direct value change (added, removed, changed) + - Check: metadata.Diff ~= nil AND metadata.Diff.type ~= "descendantChanged" + + - **Ancestor Callback**: A container table affected by descendant changes + - Check: metadata.Diff == nil OR metadata.Diff.type == "descendantChanged" + + The "descendantChanged" type is used for intermediate tables in the path + between the captured snapshot and the actual changed value. These should + be treated as ancestor notifications, not leaf changes. +]] + +return function(t: tiniest) + local ChangeDetector = require("../ChangeDetector") + + local test = t.test + local describe = t.describe + local expect = t.expect + + -- Navigates `root` via `path`, mirroring how CheckForChangesBetween's + -- callers resolve the post-mutation value at a captured path. + local function valueAt(root: any, path: { any }): any + local cursor = root + for _, key in path do + if cursor == nil then + return nil + end + cursor = cursor[key] + end + return cursor + end + + test("should capture snapshot and detect changes", function() + local changes = {} + local detector = ChangeDetector.new { + OnValueChanged = function(path, newValue, oldValue, _metadata) + table.insert(changes, { path = table.clone(path), new = newValue, old = oldValue }) + end, + } + + local myTable = { root = { x = 1, y = 2 } } + + -- Capture snapshot (returns snapshot object) + local snapshot = detector:CaptureSnapshot(myTable, { "root" }) + + -- Verify snapshot structure + expect(snapshot.RootTable).is(myTable) + expect(snapshot.Path[1]).is("root") + expect(snapshot.Data ~= nil).is_true() + expect(snapshot.Timestamp ~= nil).is_true() + + -- Make changes + myTable.root.x = 10 + myTable.root.z = 3 + + -- Check for changes via direct comparison against the captured baseline + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, { "root" }), { "root" }, myTable) + + expect(#changes >= 2).is_true() + end) + + test("should detect nested table changes with snapshot", function() + local changes = {} + local detector = ChangeDetector.new { + OnValueChanged = function(path, newValue, oldValue, metadata) + -- Only collect non-descendant leaf changes (actual value changes, not containers) + if metadata.Diff and metadata.Diff.type ~= "descendantChanged" then + table.insert(changes, { path = table.clone(path), new = newValue, old = oldValue }) + end + end, + } + + local myTable = { a = { b = 1 } } + + local snapshot = detector:CaptureSnapshot(myTable, {}) + + -- Make nested change + myTable.a.b = 2 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + -- Should get the leaf change (not the descendantChanged node for 'a') + expect(#changes).is(1) + expect(changes[1].path[1]).is("a") + expect(changes[1].path[2]).is("b") + end) + + test("should support direct comparison without snapshot", function() + local changes = {} + local detector = ChangeDetector.new { + OnValueChanged = function(path, newValue, oldValue, _metadata) + -- Only collect leaf changes (where Diff is present) + table.insert(changes, { path = table.clone(path), new = newValue, old = oldValue }) + end, + } + + local oldTable = { x = 1 } + local newTable = { x = 2 } + local rootTable = { root = newTable } + + detector:CheckForChangesBetween(oldTable, newTable, { "root" }, rootTable) + + expect(#changes).is(3) -- {}, {"root"}, {"root", "x"} + expect(changes[1].path[1]).is("root") + expect(changes[1].new).is(2) + expect(changes[1].old).is(1) + end) + + test("CheckForChangesBetween fires ancestor callbacks when root table is omitted", function() + local valuePaths = {} + local detector = ChangeDetector.new { + OnValueChanged = function(path, _newValue, _oldValue, _metadata) + table.insert(valuePaths, table.concat(path, ".")) + end, + } + + detector:CheckForChangesBetween({ x = 1 }, { x = 2 }, { "root" }) + + local sawRootAncestor = false + for _, path in valuePaths do + if path == "" then + sawRootAncestor = true + break + end + end + + expect(sawRootAncestor).is_true() + end) + + test("CheckForChangesBetween fires ancestor callbacks with explicit root table", function() + local valuePaths = {} + local root = { config = { x = 2 } } + local detector = ChangeDetector.new { + OnValueChanged = function(path, _newValue, _oldValue, _metadata) + table.insert(valuePaths, table.concat(path, ".")) + end, + } + + detector:CheckForChangesBetween({ x = 1 }, { x = 2 }, { "config" }, root) + + local sawRootAncestor = false + for _, path in valuePaths do + if path == "" then + sawRootAncestor = true + break + end + end + + expect(sawRootAncestor).is_true() + end) + + test("descendantChanged nodes fire for ancestors of a changed leaf", function() + local sawDescendantChanged = false + local detector = ChangeDetector.new { + OnValueChanged = function(_path, _newValue, _oldValue, metadata) + if metadata.Diff and metadata.Diff.type == "descendantChanged" then + sawDescendantChanged = true + end + end, + } + + local myTable = { a = { b = 1 } } + local snapshot = detector:CaptureSnapshot(myTable, {}) + myTable.a.b = 2 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + expect(sawDescendantChanged).is_true() + end) + + test("CaptureSnapshot rejects non-table roots", function() + local detector = ChangeDetector.new {} + + local ok, err = pcall(function() + detector:CaptureSnapshot(123 :: any, {}) + end) + + expect(ok).is(false) + expect(type(err)).is("string") + expect((err :: string):find("CaptureSnapshot expects rootTable to be a plain table", 1, true) ~= nil).is_true() + end) + + test("should support historical diffing with multiple snapshots", function() + local detector = ChangeDetector.new {} + + local myTable = { health = 100, mana = 50 } + + -- Capture first snapshot + local snapshot1 = detector:CaptureSnapshot(myTable, {}) + + -- Make some changes + myTable.health = 75 + myTable.mana = 60 + + -- Capture second snapshot + local snapshot2 = detector:CaptureSnapshot(myTable, {}) + + -- Make more changes + myTable.health = 50 + myTable.mana = 70 + + -- Can check against either snapshot independently + local changes1 = {} + local changes2 = {} + + local detector1 = ChangeDetector.new { + OnKeyChanged = function(_path, key, newValue, oldValue, metadata) + if metadata.Diff then + table.insert(changes1, { key = key, old = oldValue, new = newValue }) + end + end, + } + + local detector2 = ChangeDetector.new { + OnKeyChanged = function(_path, key, newValue, oldValue, metadata) + if metadata.Diff then + table.insert(changes2, { key = key, old = oldValue, new = newValue }) + end + end, + } + + -- Check against snapshot1 (100/50 -> 50/70) + detector1:CheckForChangesBetween(snapshot1.Data.value, myTable, {}, myTable) + + -- Check against snapshot2 (75/60 -> 50/70) + detector2:CheckForChangesBetween(snapshot2.Data.value, myTable, {}, myTable) + + -- Both should detect changes, but with different old values + expect(#changes1).is(2) + expect(#changes2).is(2) + + -- Find health changes + local health1, health2 + for _, change in ipairs(changes1) do + if change.key == "health" then + health1 = change + end + end + for _, change in ipairs(changes2) do + if change.key == "health" then + health2 = change + end + end + + expect(health1.old).is(100) -- From first snapshot + expect(health1.new).is(50) + expect(health2.old).is(75) -- From second snapshot + expect(health2.new).is(50) + end) + + test("should provide snapshot context in ancestor callbacks", function() + local ancestorCallbacks = {} + local detector = ChangeDetector.new { + OnValueChanged = function(path, newValue, _oldValue, metadata) + if metadata.Diff == nil then -- Ancestor callback + -- Can access snapshot for full context + table.insert(ancestorCallbacks, { + path = table.clone(path), + pathStr = table.concat(path, "."), + rootTable = metadata.Snapshot.RootTable, + newValue = newValue, -- Now contains true current value! + }) + end + end, + } + + local myTable = { player = { stats = { Health = 100, Mana = 50 } } } + + local snapshot = detector:CaptureSnapshot(myTable, { "player", "stats" }) + + -- Make changes + myTable.player.stats.Health = 75 + + detector:CheckForChangesBetween( + snapshot.Data.value, + valueAt(myTable, { "player", "stats" }), + { "player", "stats" }, + myTable + ) + + -- Should have ancestor callbacks for "player" and root + expect(#ancestorCallbacks >= 1).is_true() + + -- Verify ancestor callback receives true current value + for _, callback in ipairs(ancestorCallbacks) do + if callback.pathStr == "player" then + -- Should receive the actual player table + expect(callback.newValue).is(myTable.player) + expect(callback.newValue.stats.Health).is(75) -- Can access descendants + end + end + end) + + test("should detect table to scalar transition", function() + local changes = {} + local detector = ChangeDetector.new { + OnValueChanged = function(path, newValue, oldValue, _metadata) + table.insert(changes, { path = table.clone(path), new = newValue, old = oldValue, type = "value" }) + end, + OnKeyRemoved = function(path, key, oldValue, _metadata) + table.insert(changes, { path = table.clone(path), key = key, old = oldValue, type = "removed" }) + end, + OnKeyAdded = function(path, key, newValue, _metadata) + table.insert(changes, { path = table.clone(path), key = key, new = newValue, type = "added" }) + end, + } + + local oldTable = { config = { host = "localhost" } } + local newTable = { config = "disabled" } + + detector:CheckForChangesBetween(oldTable, newTable, {}) + + -- Should get removal of config.host, then addition of config scalar + expect(#changes >= 2).is_true() + end) + + test("should fire OnValueChanged callbacks for descendant changes in parent tables", function() + local valueChanges = {} + local keyChanges = {} + local detector = ChangeDetector.new { + OnValueChanged = function(path, _newValue, _oldValue, metadata) + local pathStr = table.concat(path, ".") + table.insert(valueChanges, { + path = table.clone(path), + pathStr = pathStr, + isLeaf = metadata.Diff ~= nil, + diffType = metadata.Diff and metadata.Diff.type or "ancestor", + }) + end, + OnKeyChanged = function(path, key, _newValue, _oldValue, metadata) + table.insert(keyChanges, { + path = table.clone(path), + key = key, + diffType = metadata.Diff and metadata.Diff.type or "ancestor", + }) + end, + } + + local myTable = { + player = { + stats = { health = 100, mana = 50 }, + position = { x = 10, y = 20 }, + }, + } + + local snapshot = detector:CaptureSnapshot(myTable, {}) + + -- Make nested change + myTable.player.stats.health = 75 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + -- Changes for paths {"player", "stats", "health"}, {"player", "stats"}, {"player"}, {} + expect(#valueChanges).is(4) + expect(valueChanges[1].path).is_shallow_equal { "player", "stats", "health" } + expect(valueChanges[2].path).is_shallow_equal { "player", "stats" } + expect(valueChanges[3].path).is_shallow_equal { "player" } + expect(valueChanges[4].path).is_shallow_equal {} + + -- Keys: "health", "stats", "player" + expect(keyChanges[1].key).is("health") + expect(keyChanges[2].key).is("stats") + expect(keyChanges[3].key).is("player") + expect(#keyChanges).is(3) + end) + + test("should fire OnValueChanged for multiple nested changes", function() + local allChanges = {} + local detector = ChangeDetector.new { + OnValueChanged = function(path, _newValue, _oldValue, metadata) + local pathStr = table.concat(path, ".") + table.insert(allChanges, { + path = table.clone(path), + pathStr = pathStr, + diffType = metadata.Diff and metadata.Diff.type or "ancestor", + }) + end, + } + + local myTable = { + player = { + stats = { health = 100 }, + position = { x = 10 }, + }, + } + + local snapshot = detector:CaptureSnapshot(myTable, { "player" }) + + -- Make changes to different branches + myTable.player.stats.health = 75 + myTable.player.position.x = 15 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, { "player" }), { "player" }, myTable) + + -- Should fire OnValueChanged callbacks for: + -- - health (changed) + -- - x (changed) + -- Plus ancestor notifications (no descendantChanged nodes fire callbacks) + expect(#allChanges >= 2).is_true() + end) + + test("should provide ChangeMetadata for all callbacks", function() + local changes = {} + local detector = ChangeDetector.new { + OnKeyChanged = function(_path, key, _newValue, _oldValue, metadata) + table.insert(changes, { + key = key, + hasMetadata = metadata ~= nil, + hasDiff = metadata and metadata.Diff ~= nil, + hasOriginPath = metadata and metadata.OriginPath ~= nil, + }) + end, + } + + local myTable = { player = { health = 100 } } + + local snapshot = detector:CaptureSnapshot(myTable, {}) + myTable.player.health = 75 + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + -- Keys: "health", "player" + expect(#changes).is(2) + end) + + test("should fire ancestor callbacks up the path hierarchy", function() + local keyChanges = {} + local detector = ChangeDetector.new { + OnKeyChanged = function(path, key, _newValue, _oldValue, metadata) + -- Ancestor = no Diff OR descendantChanged + local isAncestor = metadata.Diff == nil or metadata.Diff.type == "descendantChanged" + table.insert(keyChanges, { + path = table.clone(path), + key = key, + pathStr = table.concat(path, "."), + metadata = metadata, + isAncestor = isAncestor, + }) + end, + } + + local myTable = { Player = { Stats = { Health = 100 } } } + + -- Capture at nested path (Stats table, not Health scalar) + local snapshot = detector:CaptureSnapshot(myTable, { "Player", "Stats" }) + + -- Make change + myTable.Player.Stats.Health = 50 + + detector:CheckForChangesBetween( + snapshot.Data.value, + valueAt(myTable, { "Player", "Stats" }), + { "Player", "Stats" }, + myTable + ) + + -- Should fire callbacks for: + -- 1. Leaf: Health changed (Diff present) at path Player.Stats.Health + -- 2. Ancestor: Stats changed (at Player path, Diff nil, OriginPath = Player.Stats) + -- 3. Ancestor: Player changed (at root path, Diff nil, OriginPath = Player.Stats) + + -- Check that we got callbacks with proper info + local foundHealthLeaf = false + local foundStatsAncestor = false + local foundPlayerAncestor = false + + for _, change in ipairs(keyChanges) do + if change.key == "Health" and change.pathStr == "Player.Stats" then + foundHealthLeaf = true + -- Should be leaf callback (Diff present) + expect(change.isAncestor).never_is_true() + end + if change.key == "Stats" and change.pathStr == "Player" then + foundStatsAncestor = true + -- Should be ancestor callback (Diff nil) + expect(change.isAncestor).is_true() + -- OriginPath should point to where the assignment occurred (Player.Stats) + expect(change.metadata.OriginPath ~= nil).is_true() + local originKey = change.metadata.OriginPath[#change.metadata.OriginPath] + expect(originKey).is("Stats") + expect(#change.metadata.OriginPath).is(2) -- Player.Stats (captured path) + end + if change.key == "Player" and change.pathStr == "" then + foundPlayerAncestor = true + -- Should be ancestor callback + expect(change.isAncestor).is_true() + -- OriginPath should point to captured path + expect(change.metadata.OriginPath).exists() + expect(#change.metadata.OriginPath).is(2) -- Player.Stats + end + end + + expect(foundHealthLeaf).is_true() + expect(foundStatsAncestor).is_true() + expect(foundPlayerAncestor).is_true() + end) + + test("should fire ancestor callbacks for deeply nested snapshots", function() + local changes = {} + local detector = ChangeDetector.new { + OnKeyChanged = function(path, key, _newValue, _oldValue, metadata) + -- Ancestor = no Diff OR descendantChanged + local isAncestor = metadata.Diff == nil or metadata.Diff.type == "descendantChanged" + table.insert(changes, { + pathStr = table.concat(path, "."), + key = key, + metadata = metadata, + isAncestor = isAncestor, + }) + end, + } + + local myTable = { Root = { Game = { World = { Player = { Stats = { x = 10 } } } } } } + + -- Capture at very nested path + local path = { "Root", "Game", "World", "Player", "Stats" } + local snapshot = detector:CaptureSnapshot(myTable, path) + + myTable.Root.Game.World.Player.Stats.x = 20 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, path), path, myTable) + + -- Should fire callbacks for all ancestors plus the leaf + local ancestorKeys = {} + local leafKeys = {} + + for _, change in ipairs(changes) do + if change.isAncestor then + table.insert(ancestorKeys, change.key) + -- Ancestor callbacks have either no Diff or descendantChanged Diff + local isValidAncestor = change.metadata.Diff == nil or change.metadata.Diff.type == "descendantChanged" + expect(isValidAncestor).is_true() + expect(change.metadata.OriginPath ~= nil).is_true() + -- OriginPath should be the captured path (where assignment occurred) + local originKey = change.metadata.OriginPath[#change.metadata.OriginPath] + expect(originKey).is("Stats") + else + table.insert(leafKeys, change.key) + end + end + + -- Should have leaf change for 'x' and ancestors: Stats, Player, World, Game, Root + expect(#leafKeys).is(1) + expect(leafKeys[1]).is("x") + expect(#ancestorKeys).is(5) -- Stats, Player, World, Game, Root + end) + + test("should not fire ancestor callbacks when path is empty", function() + local keyChanges = {} + local detector = ChangeDetector.new { + OnKeyChanged = function(_path, key, _newValue, _oldValue, _metadata) + table.insert(keyChanges, { key = key }) + end, + } + + local myTable = { x = 1 } + + -- Capture at root level (empty path) + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable.x = 2 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + -- Should only have the leaf change for 'x', no ancestors + expect(#keyChanges).is(1) + expect(keyChanges[1].key).is("x") + end) + + test("should fire ancestor callbacks with CheckForChangesBetween", function() + local changes = {} + local detector = ChangeDetector.new { + OnKeyChanged = function(_path, key, _newValue, _oldValue, metadata) + table.insert(changes, { + key = key, + isAncestor = metadata.Diff == nil, + }) + end, + } + + -- Create proper nested structure for CheckForChangesBetween + local oldValue = { Player = { Stats = { Health = 100 } } } + local newValue = { Player = { Stats = { Health = 50 } } } + + -- Compare at the Stats level + detector:CheckForChangesBetween(oldValue.Player.Stats, newValue.Player.Stats, { "Player", "Stats" }) + + -- Should fire ancestor callbacks for Stats and Player + local foundStatsAncestor = false + local foundPlayerAncestor = false + for _, change in ipairs(changes) do + if change.key == "Stats" and change.isAncestor then + foundStatsAncestor = true + end + if change.key == "Player" and change.isAncestor then + foundPlayerAncestor = true + end + end + + expect(foundStatsAncestor or foundPlayerAncestor).is_true() + end) + test("should pass ChangeOrigin with full context for leaf changes", function() + local leafChanges = {} + local ancestorChanges = {} + local detector = ChangeDetector.new { + OnValueChanged = function(path, _newValue, _oldValue, metadata) + if metadata.Diff then + -- Leaf change + table.insert(leafChanges, { + path = table.clone(path), + diffType = metadata.Diff.type, + }) + else + -- Ancestor notification + table.insert(ancestorChanges, { + path = table.clone(path), + originPath = metadata.OriginPath, + }) + end + end, + } + + local myTable = { data = { x = 10, y = { z = 20 } } } + local snapshot = detector:CaptureSnapshot(myTable, { "data" }) + + myTable.data.x = 15 + myTable.data.y.z = 25 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, { "data" }), { "data" }, myTable) + + -- Check that leaf changes have Diff present + local foundXLeaf = false + local foundZLeaf = false + + for _, item in ipairs(leafChanges) do + if #item.path == 2 and item.path[2] == "x" then + foundXLeaf = true + expect(item.diffType).is("changed") + end + if #item.path == 3 and item.path[3] == "z" then + foundZLeaf = true + expect(item.diffType).is("changed") + end + end + + expect(foundXLeaf).is_true() + expect(foundZLeaf).is_true() + + -- Ancestors should have OriginPath + expect(#ancestorChanges > 0).is_true() + end) + + test("should distinguish between leaf and ancestor callbacks via metadata", function() + local callbacks = {} + local detector = ChangeDetector.new { + OnKeyChanged = function(path, key, _newValue, _oldValue, metadata) + -- Leaf callbacks have Diff with type != descendantChanged + -- Ancestor callbacks have Diff = nil OR type = descendantChanged + local isLeaf = metadata.Diff ~= nil and metadata.Diff.type ~= "descendantChanged" + table.insert(callbacks, { + pathStr = table.concat(path, "."), + key = key, + isLeafChange = isLeaf, + isAncestor = not isLeaf, + originPath = table.clone(metadata.OriginPath), + }) + end, + } + + local myTable = { Root = { Player = { value = 100 } } } + local path = { "Root", "Player" } + local snapshot = detector:CaptureSnapshot(myTable, path) + + myTable.Root.Player.value = 200 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, path), path, myTable) + + -- Should have both leaf callback and ancestor callbacks + local leafCallbacks = 0 + local ancestorCallbacks = 0 + + for _, cb in ipairs(callbacks) do + if cb.isLeafChange then + leafCallbacks = leafCallbacks + 1 + else + ancestorCallbacks = ancestorCallbacks + 1 + end + end + + expect(leafCallbacks).is(1) -- value changed at Root.Player.value + expect(ancestorCallbacks).is(2) -- Player and Root ancestors + end) + + test("should provide correct originPath for ancestor callbacks via metadata", function() + local metadatas = {} + local detector = ChangeDetector.new { + OnValueChanged = function(path, _newValue, _oldValue, metadata) + table.insert(metadatas, { + path = table.clone(path), + pathStr = table.concat(path, "."), + originPath = table.concat(metadata.OriginPath, "."), + isAncestor = metadata.Diff == nil, + }) + end, + } + + local myTable = { Root = { nested = { deeply = { value = 1 } } } } + local snapshot = detector:CaptureSnapshot(myTable, { "Root" }) + + myTable.Root.nested.deeply.value = 2 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, { "Root" }), { "Root" }, myTable) + + -- All callbacks should have OriginPath pointing to captured path (Root) + -- This is the assignment-as-operation model + local foundCapturedOrigin = false + for _, meta in ipairs(metadatas) do + if meta.originPath == "Root" then + foundCapturedOrigin = true + end + end + + -- At least one callback should point to the captured path + expect(foundCapturedOrigin).is_true() + + -- Ancestor callbacks should all have OriginPath pointing to captured path + for _, meta in ipairs(metadatas) do + if meta.isAncestor then + expect(meta.originPath).is("Root") + end + end + end) + + test("should provide ChangeMetadata with diff node for OnKeyAdded callbacks", function() + local additions = {} + local detector = ChangeDetector.new { + OnKeyAdded = function(path, key, _newValue, metadata) + table.insert(additions, { + pathStr = table.concat(path, "."), + key = key, + hasMetadata = metadata ~= nil, + hasDiff = metadata and metadata.Diff ~= nil, + diffType = metadata and metadata.Diff and metadata.Diff.type or nil, + isLeafChange = metadata and metadata.Diff ~= nil, + }) + end, + } + + local myTable = { data = { existing = 1 } } + local snapshot = detector:CaptureSnapshot(myTable, { "data" }) + + myTable.data.newKey = 100 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, { "data" }), { "data" }, myTable) + + -- Should have leaf addition with metadata + local foundLeaf = false + for _, addition in ipairs(additions) do + if addition.isLeafChange and addition.key == "newKey" then + foundLeaf = true + expect(addition.hasMetadata).is_true() + expect(addition.hasDiff).is_true() + expect(addition.diffType).is("added") + end + end + expect(foundLeaf).is_true() + end) + + test("should provide ChangeMetadata with diff node for OnKeyRemoved callbacks", function() + local removals = {} + local detector = ChangeDetector.new { + OnKeyRemoved = function(path, key, _oldValue, metadata) + table.insert(removals, { + pathStr = table.concat(path, "."), + key = key, + hasMetadata = metadata ~= nil, + hasDiff = metadata and metadata.Diff ~= nil, + diffType = metadata and metadata.Diff and metadata.Diff.type or nil, + isLeafChange = metadata and metadata.Diff ~= nil, + }) + end, + } + + local myTable = { data = { toRemove = 100, keepThis = 200 } } + local snapshot = detector:CaptureSnapshot(myTable, { "data" }) + + myTable.data.toRemove = nil + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, { "data" }), { "data" }, myTable) + + -- Should have leaf removal with metadata + local foundLeaf = false + for _, removal in ipairs(removals) do + if removal.isLeafChange and removal.key == "toRemove" then + foundLeaf = true + expect(removal.hasMetadata).is_true() + expect(removal.hasDiff).is_true() + expect(removal.diffType).is("removed") + end + end + expect(foundLeaf).is_true() + end) + + test("should derive originKey from originPath in metadata", function() + local metadatas = {} + local detector = ChangeDetector.new { + OnKeyChanged = function(_path, _key, _newValue, _oldValue, metadata) + table.insert(metadatas, metadata) + end, + } + + local myTable = { Player = { Stats = { Health = 100 } } } + local path = { "Player", "Stats" } + local snapshot = detector:CaptureSnapshot(myTable, path) + + myTable.Player.Stats.Health = 50 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, path), path, myTable) + + -- originKey should be derivable from originPath (captured path) + -- OriginPath = {"Player", "Stats"} for all callbacks + for _, metadata in ipairs(metadatas) do + local derivedKey = metadata.OriginPath[#metadata.OriginPath] + expect(derivedKey).is("Stats") + end + end) + + test("should provide rich ChangeMetadata with diff node information", function() + local allMetadata = {} + local detector = ChangeDetector.new { + OnKeyAdded = function(_path, _key, _newValue, metadata) + table.insert(allMetadata, { callback = "OnKeyAdded", metadata = metadata }) + end, + OnKeyRemoved = function(_path, _key, _oldValue, metadata) + table.insert(allMetadata, { callback = "OnKeyRemoved", metadata = metadata }) + end, + OnKeyChanged = function(_path, _key, _newValue, _oldValue, metadata) + table.insert(allMetadata, { callback = "OnKeyChanged", metadata = metadata }) + end, + OnValueChanged = function(_path, _newValue, _oldValue, metadata) + table.insert(allMetadata, { callback = "OnValueChanged", metadata = metadata }) + end, + } + + local myTable = { + data = { + toChange = 100, + toRemove = "old", + nested = { value = 1 }, + }, + } + local snapshot = detector:CaptureSnapshot(myTable, { "data" }) + + -- Make various changes + myTable.data.toChange = 200 -- changed + myTable.data.toRemove = nil -- removed + myTable.data.newKey = "added" -- added + myTable.data.nested.value = 2 -- nested change + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, { "data" }), { "data" }, myTable) + + -- Validate all metadata has required structure + for _, entry in ipairs(allMetadata) do + local metadata = entry.metadata + expect(metadata ~= nil).is_true() + expect(metadata.OriginPath ~= nil).is_true() + + -- Only leaf callbacks have non-nil Diff + if metadata.Diff ~= nil then + expect(metadata.Diff.type ~= nil).is_true() + + -- Diff node should have type, old, and new fields + local diffType = metadata.Diff.type + expect( + diffType == "added" + or diffType == "removed" + or diffType == "changed" + or diffType == "descendantChanged" + ).is_true() + end + end + + -- Should have multiple metadata entries from all callbacks + expect(#allMetadata > 0).is_true() + end) + + test("should include old and new values in diff node", function() + local changes = {} + local detector = ChangeDetector.new { + OnKeyChanged = function(_path, key, _newValue, _oldValue, metadata) + table.insert(changes, { + key = key, + diffOld = metadata.Diff.old, + diffNew = metadata.Diff.new, + diffType = metadata.Diff.type, + }) + end, + } + + local myTable = { score = 100, name = "Alice" } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable.score = 150 + myTable.name = "Bob" + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + -- Find the leaf changes and verify old/new values are in the diff + for _, change in ipairs(changes) do + if change.key == "score" and change.diffType == "changed" then + expect(change.diffOld).is(100) + expect(change.diffNew).is(150) + elseif change.key == "name" and change.diffType == "changed" then + expect(change.diffOld).is("Alice") + expect(change.diffNew).is("Bob") + end + end + end) + + test("should distinguish leaf vs ancestor via originPath length in metadata", function() + local callbacks = {} + local detector = ChangeDetector.new { + OnValueChanged = function(path, _newValue, _oldValue, metadata) + table.insert(callbacks, { + path = table.clone(path), + pathStr = table.concat(path, "."), + isLeaf = metadata.Diff ~= nil, + isAncestor = metadata.Diff == nil, + }) + end, + } + + local myTable = { level1 = { level2 = { value = 1 } } } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable.level1.level2.value = 2 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + -- Should have leaf (Diff present) and ancestors (Diff nil) + local leaves = 0 + local ancestors = 0 + + for _, cb in ipairs(callbacks) do + if cb.isLeaf then + leaves = leaves + 1 + elseif cb.isAncestor then + ancestors = ancestors + 1 + end + end + + -- One leaf (the actual changed value) + -- No descendantChanged nodes should fire (they're containers) + local deepestLeaf = 0 + for _, cb in ipairs(callbacks) do + if cb.isLeaf and #cb.path == 3 then -- level1.level2.value + deepestLeaf = deepestLeaf + 1 + end + end + + expect(deepestLeaf).is(1) -- The actual changed value at deepest level + expect(ancestors >= 0).is_true() -- May have ancestor notifications + end) + + -- Edge Case Tests + describe("Edge Cases", function() + test("should handle nil to value transition", function() + local changes = {} + local detector = ChangeDetector.new { + OnKeyAdded = function(_path, key, newValue, metadata) + table.insert(changes, { key = key, new = newValue, type = "added", hasDiff = metadata.Diff ~= nil }) + end, + } + + local myTable = { x = nil } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable.x = 10 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + expect(#changes).is(1) + expect(changes[1].key).is("x") + expect(changes[1].new).is(10) + expect(changes[1].hasDiff).is_true() + end) + + test("should handle value to nil transition", function() + local changes = {} + local detector = ChangeDetector.new { + OnKeyRemoved = function(_path, key, oldValue, metadata) + table.insert( + changes, + { key = key, old = oldValue, type = "removed", hasDiff = metadata.Diff ~= nil } + ) + end, + } + + local myTable = { x = 10 } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable.x = nil + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + expect(#changes).is(1) + expect(changes[1].key).is("x") + expect(changes[1].old).is(10) + expect(changes[1].hasDiff).is_true() + end) + + test("should handle empty table changes", function() + local changes = {} + local detector = ChangeDetector.new { + OnValueChanged = function(_path, _newValue, _oldValue, metadata) + if metadata.Diff then + table.insert(changes, { type = metadata.Diff.type }) + end + end, + } + + local myTable = {} + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable.newKey = "added" + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + expect(#changes >= 1).is_true() + end) + + test("should handle empty table to empty table (no changes)", function() + local changes = {} + local detector = ChangeDetector.new { + OnValueChanged = function(_path, _newValue, _oldValue, _metadata) + table.insert(changes, {}) + end, + } + + local myTable = {} + local snapshot = detector:CaptureSnapshot(myTable, {}) + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + expect(#changes).is(0) + end) + + test("should handle multiple changes to same key", function() + local changes = {} + local detector = ChangeDetector.new { + OnKeyChanged = function(_path, key, newValue, oldValue, metadata) + if metadata.Diff then + table.insert(changes, { key = key, new = newValue, old = oldValue }) + end + end, + } + + local myTable = { x = 1 } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable.x = 2 + myTable.x = 3 -- Only final state should be detected + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + expect(#changes).is(1) + expect(changes[1].old).is(1) + expect(changes[1].new).is(3) -- Should see final value, not intermediate + end) + + test("should handle deeply nested nil values", function() + local changes = {} + local detector = ChangeDetector.new { + OnValueChanged = function(path, _newValue, _oldValue, metadata) + if metadata.Diff and metadata.Diff.type ~= "descendantChanged" then + table.insert(changes, { path = table.clone(path) }) + end + end, + } + + local myTable = { a = { b = { c = nil } } } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable.a.b.c = 10 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + expect(#changes).is(1) + expect(changes[1].path[1]).is("a") + expect(changes[1].path[2]).is("b") + expect(changes[1].path[3]).is("c") + end) + + test("should handle table replaced with different table", function() + local changes = {} + local detector = ChangeDetector.new { + OnValueChanged = function(_path, _newValue, _oldValue, metadata) + if metadata.Diff then + table.insert(changes, { type = metadata.Diff.type }) + end + end, + } + + local myTable = { nested = { x = 1 } } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable.nested = { y = 2 } -- Completely different table + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + -- Should detect removal of x and addition of y + expect(#changes >= 2).is_true() + end) + + test("should handle scalar to table transition", function() + local removals = {} + local additions = {} + local changes = {} + local detector = ChangeDetector.new { + OnKeyRemoved = function(_path, key, _oldValue, metadata) + if metadata.Diff then + table.insert(removals, { key = key }) + end + end, + OnKeyAdded = function(_path, key, _newValue, metadata) + if metadata.Diff then + table.insert(additions, { key = key }) + end + end, + OnKeyChanged = function(_path, key, newValue, oldValue, metadata) + if metadata.Diff then + table.insert(changes, { key = key, old = oldValue, new = newValue }) + end + end, + } + + local myTable = { config = "disabled" } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable.config = { host = "localhost" } + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + -- scalar -> table is a single "changed" at the key (NOT a removal), + -- carrying the whole new table, plus the new table's keys reported + -- as additions for granular listeners. + expect(#removals).is(0) + expect(#changes).is(1) + expect(changes[1].key).is("config") + expect(changes[1].old).is("disabled") + expect(type(changes[1].new)).is("table") + expect(#additions >= 1).is_true() + end) + + test("table to scalar transition fires subtree teardown before the scalar event", function() + -- Regression guard: the scalar-sentinel child used to be processed in + -- pairs() order among the real children, so a consumer could receive + -- the parent's scalar value FIRST and then fail to apply the + -- (now-redundant) child removals through a non-table value. The + -- sentinel must always fire LAST. + local events = {} + local detector = ChangeDetector.new { + OnValueChanged = function(path, newValue, _oldValue, metadata) + if metadata.Diff and metadata.Diff.type ~= "descendantChanged" then + table.insert(events, { path = table.clone(path), new = newValue }) + end + end, + } + + local myTable = { config = { host = "localhost", ports = { 80, 443 } } } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable.config = "disabled" + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + -- Teardown events for the old subtree (paths deeper than {config}) + -- must all precede the single scalar event at {config}, which is last. + expect(#events >= 2).is_true() + local lastEvent = events[#events] + expect(#lastEvent.path).is(1) + expect(lastEvent.path[1]).is("config") + expect(lastEvent.new).is("disabled") + for i = 1, #events - 1 do + expect(events[i].path[1]).is("config") + expect(#events[i].path > 1).with_context(`event #{i} should be a descendant teardown`).is_true() + end + end) + + test("should handle mixed changes in single operation", function() + local allChanges = {} + local detector = ChangeDetector.new { + OnKeyAdded = function(_path, key, _newValue, metadata) + if metadata.Diff then + table.insert(allChanges, { key = key, type = "added" }) + end + end, + OnKeyRemoved = function(_path, key, _oldValue, metadata) + if metadata.Diff then + table.insert(allChanges, { key = key, type = "removed" }) + end + end, + OnKeyChanged = function(_path, key, _newValue, _oldValue, metadata) + if metadata.Diff then + table.insert(allChanges, { key = key, type = "changed" }) + end + end, + } + + local myTable = { a = 1, b = 2, c = 3 } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable.a = 10 -- changed + myTable.b = nil -- removed + myTable.d = 4 -- added + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + -- Should have 3 changes: 1 added, 1 removed, 1 changed + expect(#allChanges).is(3) + + local foundAdded = false + local foundRemoved = false + local foundChanged = false + for _, change in ipairs(allChanges) do + if change.key == "d" and change.type == "added" then + foundAdded = true + end + if change.key == "b" and change.type == "removed" then + foundRemoved = true + end + if change.key == "a" and change.type == "changed" then + foundChanged = true + end + end + + expect(foundAdded).is_true() + expect(foundRemoved).is_true() + expect(foundChanged).is_true() + end) + + test("should handle changes at root level with empty path", function() + local changes = {} + local detector = ChangeDetector.new { + OnValueChanged = function(path, _newValue, _oldValue, metadata) + if metadata.Diff and metadata.Diff.type ~= "descendantChanged" then + table.insert(changes, { pathLength = #path }) + end + end, + } + + local oldTable = { x = 1 } + local newTable = { x = 2 } + + detector:CheckForChangesBetween(oldTable, newTable, {}) + + expect(#changes).is(1) + expect(changes[1].pathLength).is(1) -- Path should be {x} + end) + + test("should handle no changes when values are identical", function() + local changes = {} + local detector = ChangeDetector.new { + OnValueChanged = function(_path, _newValue, _oldValue, _metadata) + table.insert(changes, {}) + end, + } + + local myTable = { x = 1, y = { z = 2 } } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + -- No changes made + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + expect(#changes).is(0) + end) + + test("should handle boolean value changes", function() + local changes = {} + local detector = ChangeDetector.new { + OnKeyChanged = function(_path, key, newValue, oldValue, metadata) + if metadata.Diff then + table.insert(changes, { key = key, new = newValue, old = oldValue }) + end + end, + } + + local myTable = { enabled = true } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable.enabled = false + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + expect(#changes).is(1) + expect(changes[1].old).is(true) + expect(changes[1].new).is(false) + end) + + test("should handle string value changes", function() + local changes = {} + local detector = ChangeDetector.new { + OnKeyChanged = function(_path, key, newValue, oldValue, metadata) + if metadata.Diff then + table.insert(changes, { key = key, new = newValue, old = oldValue }) + end + end, + } + + local myTable = { name = "Alice" } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable.name = "Bob" + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + expect(#changes).is(1) + expect(changes[1].old).is("Alice") + expect(changes[1].new).is("Bob") + end) + + test("should handle number to string transition", function() + local changes = {} + local detector = ChangeDetector.new { + OnKeyChanged = function(_path, key, newValue, oldValue, metadata) + if metadata.Diff then + table.insert(changes, { + key = key, + new = newValue, + old = oldValue, + newType = type(newValue), + oldType = type(oldValue), + }) + end + end, + } + + local myTable = { value = 123 } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable.value = "123" + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + expect(#changes).is(1) + expect(changes[1].oldType).is("number") + expect(changes[1].newType).is("string") + end) + + test("should handle array-like tables", function() + local changes = {} + local detector = ChangeDetector.new { + OnValueChanged = function(_path, _newValue, _oldValue, metadata) + if metadata.Diff and metadata.Diff.type ~= "descendantChanged" then + table.insert(changes, { type = metadata.Diff.type }) + end + end, + } + + local myTable = { 1, 2, 3 } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable[2] = 20 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + expect(#changes).is(1) + end) + + test("should handle keys with special characters", function() + local changes = {} + local detector = ChangeDetector.new { + OnKeyChanged = function(_path, key, _newValue, _oldValue, metadata) + if metadata.Diff then + table.insert(changes, { key = key }) + end + end, + } + + local myTable = { ["key-with-dash"] = 1, ["key.with.dot"] = 2 } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable["key-with-dash"] = 10 + myTable["key.with.dot"] = 20 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + expect(#changes).is(2) + end) + + test("should handle origin tracking with multiple sibling changes", function() + local ancestorCallbacks = {} + local leafCallbacks = {} + local detector = ChangeDetector.new { + OnKeyChanged = function(path, key, _newValue, _oldValue, metadata) + -- Ancestor = no Diff OR descendantChanged + local isAncestor = metadata.Diff == nil or metadata.Diff.type == "descendantChanged" + if isAncestor then + table.insert(ancestorCallbacks, { + key = key, + path = table.clone(path), + pathStr = table.concat(path, "."), + originPath = table.concat(metadata.OriginPath, "."), + }) + else -- Leaf callback + table.insert(leafCallbacks, { + key = key, + path = table.clone(path), + pathStr = table.concat(path, "."), + }) + end + end, + } + local myTable = { Root = { a = 1, b = 2 } } + local snapshot = detector:CaptureSnapshot(myTable, { "Root" }) + + myTable.Root.a = 10 + myTable.Root.b = 20 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, { "Root" }), { "Root" }, myTable) + + -- Should have leaf callbacks for 'a' and 'b' + expect(#leafCallbacks).is(2) + + -- Should have ancestor callback with OriginPath pointing to captured path (Root) + -- In the assignment-as-operation model, all changes share the same origin + expect(#ancestorCallbacks >= 1).is_true() + + -- OriginPath should point to captured path (Root), not individual leaves + if #ancestorCallbacks > 0 then + local origin = ancestorCallbacks[1].originPath + expect(origin).is("Root") + end + end) + + test("should handle very deep nesting", function() + local changes = {} + local detector = ChangeDetector.new { + OnValueChanged = function(path, _newValue, _oldValue, metadata) + if metadata.Diff and metadata.Diff.type ~= "descendantChanged" then + table.insert(changes, { depth = #path }) + end + end, + } + + local myTable = { a = { b = { c = { d = { e = { f = 1 } } } } } } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable.a.b.c.d.e.f = 2 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + expect(#changes).is(1) + expect(changes[1].depth).is(6) -- 6 levels deep + end) + + test("should handle metadata OriginDiff for ancestor callbacks", function() + local ancestorCallbacks = {} + local detector = ChangeDetector.new { + OnValueChanged = function(_path, _newValue, _oldValue, metadata) + if metadata.Diff == nil then -- Ancestor callback + table.insert(ancestorCallbacks, { + hasOriginDiff = metadata.OriginDiff ~= nil, + originDiffType = metadata.OriginDiff and metadata.OriginDiff.type, + }) + end + end, + } + + local myTable = { nested = { value = 1 } } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable.nested.value = 2 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + -- Ancestor callbacks should have OriginDiff + if #ancestorCallbacks > 0 then + expect(ancestorCallbacks[1].hasOriginDiff).is_true() + expect(ancestorCallbacks[1].originDiffType ~= nil).is_true() + end + end) + + test("should handle table with numeric and string keys mixed", function() + local changes = {} + local detector = ChangeDetector.new { + OnKeyChanged = function(_path, key, _newValue, _oldValue, metadata) + if metadata.Diff then + table.insert(changes, { key = key, keyType = type(key) }) + end + end, + } + + local myTable = { [1] = "one", [2] = "two", name = "test" } + local snapshot = detector:CaptureSnapshot(myTable, {}) + + myTable[1] = "ONE" + myTable.name = "TEST" + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, {}), {}, myTable) + + expect(#changes).is(2) + + local foundNumericKey = false + local foundStringKey = false + for _, change in ipairs(changes) do + if change.keyType == "number" then + foundNumericKey = true + end + if change.keyType == "string" then + foundStringKey = true + end + end + + expect(foundNumericKey).is_true() + expect(foundStringKey).is_true() + end) + + test("should handle changes to a path that resolves to nil", function() + local changes = {} + local detector = ChangeDetector.new { + OnValueChanged = function(path, _newValue, _oldValue, metadata) + if metadata.Diff and metadata.Diff.type ~= "descendantChanged" then + table.insert(changes, { pathStr = table.concat(path, ".") }) + end + end, + } + + local myTable = { a = { b = nil } } + local path = { "a", "b" } + local snapshot = detector:CaptureSnapshot(myTable, path) + + myTable.a.b = 10 + + detector:CheckForChangesBetween(snapshot.Data.value, valueAt(myTable, path), path, myTable) + + expect(#changes).is(1) + expect(changes[1].pathStr).is("a.b") + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/CoalescedFlush.spec.luau b/lib/tablemanager2/src/Tests/CoalescedFlush.spec.luau new file mode 100644 index 00000000..407ef715 --- /dev/null +++ b/lib/tablemanager2/src/Tests/CoalescedFlush.spec.luau @@ -0,0 +1,290 @@ +--!strict +--[[ + Tests for `Config.FlushMode = "coalesced"` (CoalescedFlush.luau): instead + of running a direct write's diff+fire+reconcile cycle (`_doFlush`) + synchronously, it defers that cycle to frame-end (`task.defer`) and merges + every path flushed before then into one pending flush root — their common + ancestor — so N writes under one subtree in a frame cost one shadow + diff/reconcile instead of N. Covers: + - The basic defer-and-merge-to-one-fire behavior, frame-start old / final new. + - The ancestor-merge math itself (siblings collapse up; a descendant of the + current root doesn't widen it; unrelated branches widen to the root). + - The persistent shadow staying at its frame-start baseline until the + deferred flush actually runs. + - A write made from inside the deferred flush's own listener still gets a + correct, separate flush (not lost, not double-fired). + - `Suspend`/`Flush` draining any still-pending coalesced flush first, so a + baseline read taken directly off live data (Batch's pre-batch snapshot, + an explicit Flush) can never silently absorb a not-yet-reported + transition the pending flush was holding. + - `FlushMode = "immediate"` (the default) is unchanged. +]] + +return function(t: tiniest) + const TableManager = require("../TableManager") + + const test = t.test + const describe = t.describe + const expect = t.expect + + describe("FlushMode = coalesced", function() + test("multiple Sets to the same path in a frame fire exactly once at frame-end with frame-start old and final new", function() + const manager = TableManager.new({ + player = { hp = 100 }, + }, { + FlushMode = "coalesced", + }) + + const fires: { { new: any, old: any } } = {} + manager:OnValueChange("player.hp", function(newValue, oldValue) + table.insert(fires, { new = newValue, old = oldValue }) + end) + + manager:Set("player.hp", 90) + manager:Set("player.hp", 80) + manager:Set("player.hp", 70) + + expect(#fires).is(0) -- flush deferred; nothing fired synchronously + expect(manager:Get("player.hp")).is(70) -- the write itself is never deferred + + task.wait() + + expect(#fires).is(1) + expect(fires[1].old).is(100) + expect(fires[1].new).is(70) + + manager:Destroy() + end) + + test("sibling deep writes in a frame collapse the pending root to their common ancestor; each leaf still fires once", function() + const manager = TableManager.new({ + a = { b = { x = 1 }, c = { y = 2 } }, + }, { + FlushMode = "coalesced", + }) + + const bxFires: { { new: any, old: any } } = {} + const cyFires: { { new: any, old: any } } = {} + manager:OnValueChange("a.b.x", function(newValue, oldValue) + table.insert(bxFires, { new = newValue, old = oldValue }) + end) + manager:OnValueChange("a.c.y", function(newValue, oldValue) + table.insert(cyFires, { new = newValue, old = oldValue }) + end) + + manager:Set("a.b.x", 10) + expect((manager :: any)._pendingFlushRoot).is_shallow_equal { "a", "b", "x" } + + manager:Set("a.c.y", 20) + expect((manager :: any)._pendingFlushRoot).is_shallow_equal { "a" } + + expect(#bxFires).is(0) + expect(#cyFires).is(0) + + task.wait() + + expect(#bxFires).is(1) + expect(bxFires[1].old).is(1) + expect(bxFires[1].new).is(10) + expect(#cyFires).is(1) + expect(cyFires[1].old).is(2) + expect(cyFires[1].new).is(20) + + manager:Destroy() + end) + + test("a descendant write after an ancestor write does not widen the pending root", function() + const manager = TableManager.new({ + a = { b = { x = 1 } }, + }, { + FlushMode = "coalesced", + }) + manager:OnValueChange("a.b", function() end) + manager:OnValueChange("a.b.x", function() end) + + manager:Set("a.b", { x = 2 }) + expect((manager :: any)._pendingFlushRoot).is_shallow_equal { "a", "b" } + + -- {"a","b","x"} is a descendant of the current root {"a","b"}; the + -- common ancestor of the two is still {"a","b"} -- it must not widen. + manager:Set("a.b.x", 3) + expect((manager :: any)._pendingFlushRoot).is_shallow_equal { "a", "b" } + + task.wait() + expect(manager:Get("a.b.x")).is(3) + + manager:Destroy() + end) + + test("writes under unrelated root branches widen the pending root to the whole tree; each leaf still fires once", function() + const manager = TableManager.new({ + a = { x = 1 }, + q = { y = 2 }, + }, { + FlushMode = "coalesced", + }) + + local aFires, qFires = 0, 0 + manager:OnValueChange("a.x", function() + aFires += 1 + end) + manager:OnValueChange("q.y", function() + qFires += 1 + end) + + manager:Set("a.x", 10) + manager:Set("q.y", 20) + + expect((manager :: any)._pendingFlushRoot).is_shallow_equal {} + + task.wait() + + expect(aFires).is(1) + expect(qFires).is(1) + expect(manager:Get("a.x")).is(10) + expect(manager:Get("q.y")).is(20) + + manager:Destroy() + end) + + test("the shadow stays at its frame-start baseline until the deferred flush actually runs", function() + const manager = TableManager.new({ x = 1 }, { FlushMode = "coalesced" }) + manager:OnValueChange("x", function() end) -- make "x" trackable + + manager:Set("x", 2) + expect((manager :: any)._shadow:Get({ "x" })).is(1) + + manager:Set("x", 3) + expect((manager :: any)._shadow:Get({ "x" })).is(1) -- still frame-start, not 2 + + local capturedOld: any + manager:OnValueChange("x", function(_newValue, oldValue) + capturedOld = oldValue + end) + + task.wait() + + expect(capturedOld).is(1) + expect((manager :: any)._shadow:Get({ "x" })).is(3) + + manager:Destroy() + end) + + test("a write made from inside the deferred flush's own listener still gets a correct, separate flush", function() + const manager = TableManager.new({ x = 1, y = 10 }, { FlushMode = "coalesced" }) + + local yFireCount = 0 + local capturedYOld: any + manager:OnValueChange("y", function(_newValue, oldValue) + yFireCount += 1 + capturedYOld = oldValue + end) + + local reentered = false + manager:OnValueChange("x", function() + if not reentered then + reentered = true + manager:Set("y", 20) -- re-entrant write during the x flush + end + end) + + manager:Set("x", 2) + + -- The re-entrant y-write schedules its OWN coalesced flush, which may + -- land on a later defer tick than the one that ran the x flush -- wait + -- across a bounded number of ticks rather than assuming exactly one. + for _ = 1, 5 do + if yFireCount > 0 then + break + end + task.wait() + end + + expect(yFireCount).is(1) -- exactly once: not lost, not double-fired + expect(capturedYOld).is(10) + expect(manager:Get("y")).is(20) + + manager:Destroy() + end) + + test("Batch force-flushes a pending coalesced write first, so no transition is dropped across Suspend's snapshot", function() + const manager = TableManager.new({ x = 0 }, { FlushMode = "coalesced" }) + + const seen: { { new: any, old: any } } = {} + manager:OnValueChange("x", function(newValue, oldValue) + table.insert(seen, { new = newValue, old = oldValue }) + end) + + manager:Set("x", 1) -- schedules a coalesced flush; not yet drained + + manager:Batch(function() + manager:Set("x", 2) + end) + + -- Resume runs synchronously; it must have force-flushed the pending + -- "0 -> 1" transition BEFORE Suspend took its own pre-batch snapshot, + -- otherwise that transition is silently absorbed and never reported. + expect(#seen).is(2) + expect(seen[1].old).is(0) + expect(seen[1].new).is(1) + expect(seen[2].old).is(1) + expect(seen[2].new).is(2) + + -- No leftover pending state, and no double-fire when the original + -- (now-stale) deferred flush eventually runs. + task.wait() + expect(#seen).is(2) + + manager:Destroy() + end) + + test("an explicit Flush drains a pending coalesced write first, so no transition is dropped", function() + const manager = TableManager.new({ x = 0 }, { FlushMode = "coalesced" }) + + const seen: { { new: any, old: any } } = {} + manager:OnValueChange("x", function(newValue, oldValue) + table.insert(seen, { new = newValue, old = oldValue }) + end) + + manager:Set("x", 1) -- schedules a coalesced flush; not yet drained + + -- A direct Raw mutation that bypasses Set/Proxy entirely, then an + -- explicit Flush to surface it -- Flush's documented use case. + const raw: any = manager.Raw + raw.x = 2 + + manager:Flush("x") + + expect(#seen).is(1) + expect(seen[1].old).is(0) + expect(seen[1].new).is(2) + + task.wait() + expect(#seen).is(1) -- the now-stale deferred flush is a no-op + + manager:Destroy() + end) + + test("FlushMode = immediate (the default) still flushes synchronously per Set, unchanged", function() + const manager = TableManager.new { x = 1 } + + local fireCount = 0 + local lastNew: any, lastOld: any + manager:OnValueChange("x", function(newValue, oldValue) + fireCount += 1 + lastNew = newValue + lastOld = oldValue + end) + + manager:Set("x", 2) + expect(fireCount).is(1) -- fired synchronously, no task.wait() needed + expect(lastNew).is(2) + expect(lastOld).is(1) + + manager:Set("x", 3) + expect(fireCount).is(2) + + manager:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/Diff.spec.luau b/lib/tablemanager2/src/Tests/Diff.spec.luau new file mode 100644 index 00000000..94488043 --- /dev/null +++ b/lib/tablemanager2/src/Tests/Diff.spec.luau @@ -0,0 +1,225 @@ +--!strict +--[[ + Unit tests for Diff module snapshot-based diffing. +]] + +return function(t: tiniest) + local Diff = require("../Diff") + + local test = t.test + local describe = t.describe + local expect = t.expect + + -- Stub oracle: `opaqueSet`/`childrenSet` are plain reference-keyed sets + -- (not real OpaqueRegistry registries) so these tests probe Diff's ctx + -- contract in isolation, independent of OpaqueRegistry. + local function makeCtx(opaqueSet: { [any]: true }?, childrenSet: { [any]: true }?): Diff.Ctx + local opaque = opaqueSet or {} + local children = childrenSet or {} + return { + isOpaque = function(ref: any): boolean + return opaque[ref] == true + end, + hasOpaqueChildren = function(ref: any): boolean + return children[ref] == true + end, + } + end + + test("direct diff detects a changed key between two plain tables", function() + local root = Diff.diff({ x = 1, nested = { y = 2 } }, { x = 5, nested = { y = 2 } }, nil, nil, nil) + local flat = Diff.flatten(root, {}) + + expect(#flat >= 1).is_true() + local foundXChange = false + for _, entry in flat do + if #entry.path == 1 and entry.path[1] == "x" and entry.type == "changed" and entry.new == 5 then + foundXChange = true + end + end + expect(foundXChange).is_true() + end) + + test("ctx == nil behaves identically to omitting it (regression path)", function() + local root = Diff.diff({ a = { b = 1 } }, { a = { b = 2 } }, nil, nil, nil) + local flat = Diff.flatten(root, {}) + + local foundBChange = false + for _, entry in flat do + if #entry.path == 2 and entry.path[2] == "b" and entry.type == "changed" and entry.new == 2 then + foundBChange = true + end + end + expect(foundBChange).is_true() + end) + + describe("opacity oracle", function() + test("snapshot of an opaque value is a live-value leaf, not a frozen clone", function() + local obj = { internal = { 1, 2, 3 } } + local ctx = makeCtx({ [obj] = true }) + + local snap = Diff.snapshot(obj, ctx) + + expect(snap.opaque).is_true() + expect(snap.children).is(nil) + expect(snap.ref).is(obj) + expect(snap.value).is(obj) + expect(table.isfrozen(snap.value :: any)).never_is_true() + end) + + test("same ref on both sides of an opaque slot produces no diff, even if internals mutated", function() + local obj = { internal = 1 } + local ctx = makeCtx({ [obj] = true }) + + local oldValue = { a = obj } + obj.internal = 999 -- internal mutation must be invisible: opacity compares by ref + local root = Diff.diff(oldValue, { a = obj }, nil, nil, ctx) + + expect(root).is(nil) + end) + + test("an opaque value added wholesale produces a leaf, not a child walk", function() + local obj = { internal = { deep = { 1, 2, 3 } } } + local ctx = makeCtx({ [obj] = true }) + + local root = Diff.diff({}, { a = obj }, nil, nil, ctx) + + local aNode = root and root.children and root.children["a"] + expect(aNode).exists() + expect((aNode :: Diff.DiffNode).type).is("added") + expect((aNode :: Diff.DiffNode).children).is(nil) + expect((aNode :: Diff.DiffNode).new).is(obj) + end) + + test("an opaque value removed wholesale produces a leaf, not a child walk", function() + local obj = { internal = { deep = { 1, 2, 3 } } } + local ctx = makeCtx({ [obj] = true }) + + local root = Diff.diff({ a = obj }, {}, nil, nil, ctx) + + local aNode = root and root.children and root.children["a"] + expect(aNode).exists() + expect((aNode :: Diff.DiffNode).type).is("removed") + expect((aNode :: Diff.DiffNode).children).is(nil) + expect((aNode :: Diff.DiffNode).old).is(obj) + end) + + test("a non-opaque parent still diffs structurally around an opaque child leaf", function() + local opaqueChild = { secret = 1 } + local ctx = makeCtx({ [opaqueChild] = true }) + + local newOpaqueChild = { secret = 2 } + local root = + Diff.diff({ visible = 1, hidden = opaqueChild }, { visible = 2, hidden = newOpaqueChild }, nil, nil, ctx) + + expect(root).exists() + local visibleNode = root and root.children and root.children["visible"] + local hiddenNode = root and root.children and root.children["hidden"] + expect((visibleNode :: Diff.DiffNode).type).is("changed") + expect((hiddenNode :: Diff.DiffNode).type).is("changed") + expect((hiddenNode :: Diff.DiffNode).children).is(nil) + expect((hiddenNode :: Diff.DiffNode).new).is(newOpaqueChild) + end) + + describe("OpaqueChildren (hasOpaqueChildren)", function() + test("per-child internal mutation is invisible; the container itself stays transparent", function() + local container = {} + local child = { hp = 100 } + container.child = child + local ctx = makeCtx(nil, { [container] = true }) + + -- A fresh table standing in for container's pre-mutation shape: only + -- `container`'s own ref is registered, so it's the side that must + -- carry the OpaqueChildren marking for the fallback to trigger. + local oldValue = { child = child } + child.hp = 50 -- internal mutation on the still-opaque child; must be ignored + local root = Diff.diff(oldValue, container, nil, nil, ctx) + + expect(root).is(nil) + end) + + test("a child ref swap is reported as a single changed leaf", function() + local container = {} + local childA = { hp = 100 } + local childB = { hp = 999 } + container.slot = childA + local ctx = makeCtx(nil, { [container] = true }) + + local oldValue = { slot = childA } + container.slot = childB + local root = Diff.diff(oldValue, container, nil, nil, ctx) + + local slotNode = root and root.children and root.children["slot"] + expect(slotNode).exists() + expect((slotNode :: Diff.DiffNode).type).is("changed") + expect((slotNode :: Diff.DiffNode).children).is(nil) + expect((slotNode :: Diff.DiffNode).new).is(childB) + end) + + test("adding a child is tracked as a normal opaque-leaf addition", function() + local container = { existing = { hp = 1 } } + local ctx = makeCtx(nil, { [container] = true }) + + local oldValue = { existing = container.existing } + container.fresh = { hp = 2 } + local root = Diff.diff(oldValue, container, nil, nil, ctx) + + local freshNode = root and root.children and root.children["fresh"] + expect(freshNode).exists() + expect((freshNode :: Diff.DiffNode).type).is("added") + expect((freshNode :: Diff.DiffNode).children).is(nil) + end) + + test("removing a child is tracked as a normal opaque-leaf removal", function() + local childToRemove = { hp = 1 } + local container = { gone = childToRemove } + local ctx = makeCtx(nil, { [container] = true }) + + local oldValue = { gone = childToRemove } + container.gone = nil + local root = Diff.diff(oldValue, container, nil, nil, ctx) + + local goneNode = root and root.children and root.children["gone"] + expect(goneNode).exists() + expect((goneNode :: Diff.DiffNode).type).is("removed") + expect((goneNode :: Diff.DiffNode).children).is(nil) + end) + end) + + describe("direct diff with no snapshots (the CheckForChangesBetween shape)", function() + test("an opaque slot is still detected via the ctx fallback", function() + local objA = { x = 1 } + local objB = { x = 2 } + local ctx = makeCtx({ [objA] = true, [objB] = true }) + + local root = Diff.diff({ a = objA }, { a = objB }, nil, nil, ctx) + + expect(root).exists() + local aNode = root and root.children and root.children["a"] + expect(aNode).exists() + expect((aNode :: Diff.DiffNode).type).is("changed") + expect((aNode :: Diff.DiffNode).children).is(nil) + expect((aNode :: Diff.DiffNode).new).is(objB) + expect((aNode :: Diff.DiffNode).old).is(objA) + end) + + test("hasOpaqueChildren is honored via the ctx fallback", function() + local container = {} + local childA = { x = 1 } + container.slot = childA + local ctx = makeCtx(nil, { [container] = true }) + + local childB = { x = 2 } + local newContainer = { slot = childB } + + local root = Diff.diff(container, newContainer, nil, nil, ctx) + + expect(root).exists() + local slotNode = root and root.children and root.children["slot"] + expect(slotNode).exists() + expect((slotNode :: Diff.DiffNode).type).is("changed") + expect((slotNode :: Diff.DiffNode).children).is(nil) + end) + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/Helpers/GcHelpers.luau b/lib/tablemanager2/src/Tests/Helpers/GcHelpers.luau new file mode 100644 index 00000000..3e1a3939 --- /dev/null +++ b/lib/tablemanager2/src/Tests/Helpers/GcHelpers.luau @@ -0,0 +1,64 @@ +--!strict +--[[ + Test helpers for memory-leak specs. + + Luau supports weak tables but NOT forced garbage collection (`collectgarbage` + only accepts "count") and has no __gc metamethod. Collectability is therefore + probed by holding the target in a weak-valued table and driving the + incremental collector with allocation pressure until the weak reference + clears or an iteration cap is reached. +]] + +--[=[ + @class GcHelpers + @ignore + + Test helpers for memory-leak specs. +]=] +const GcHelpers = {} + +-- Elements per throwaway allocation in WaitForCollection. Large enough that the +-- cap'd loop allocates tens of MB total, which spans several full GC cycles. +const PRESSURE_ALLOCATION_SIZE = 4096 +const DEFAULT_MAX_STEPS = 10000 + +--[[ + Holds `value` in a weak-valued table and returns a probe function reporting + whether the value is still uncollected. Callers must drop every strong + reference to `value` (including local variables) before polling the probe. +]] +function GcHelpers.WeakProbe(value: any): () -> boolean + const holder = setmetatable({ value }, { __mode = "v" }) + return function(): boolean + return holder[1] ~= nil + end +end + +--[[ + Allocates throwaway garbage (which advances Luau's incremental collector, + stepping proportionally to allocation volume) until `isAlive` reports + collection or `maxSteps` allocations have been made. + Returns true if the probed value was collected. +]] +function GcHelpers.WaitForCollection(isAlive: () -> boolean, maxSteps: number?): boolean + const steps = maxSteps or DEFAULT_MAX_STEPS + for i = 1, steps do + if not isAlive() then + return true + end + const garbage = table.create(PRESSURE_ALLOCATION_SIZE, i) + garbage[1] = i + end + return not isAlive() +end + +--- Counts the keys currently present in a (possibly weak) map. +function GcHelpers.CountKeys(map: { [any]: any }): number + local count = 0 + for _ in map do + count += 1 + end + return count +end + +return GcHelpers diff --git a/lib/tablemanager2/src/Tests/Helpers/ReplicationHarness.luau b/lib/tablemanager2/src/Tests/Helpers/ReplicationHarness.luau new file mode 100644 index 00000000..6645f61c --- /dev/null +++ b/lib/tablemanager2/src/Tests/Helpers/ReplicationHarness.luau @@ -0,0 +1,582 @@ +--!strict +--[[ + Test helper for the replication-fidelity spec. + + Wires a SOURCE TableManager to an independent REPLICA TableManager using + only one of two candidate "replication feeds": + + - "signals": the leaf-only public Signals (ValueChanged, ArrayInserted, + ArrayRemoved, ArraySet). KeyAdded/KeyRemoved/KeyChanged are connected + for diagnostics only (logged, never applied) since they are redundant + with the above for replication purposes. + - "diff": the metadata.OriginDiff tree delivered once per operation to a + root `OnChange({}, ...)` listener. Array operations arrive tagged with + metadata.ArrayOp (explicit shift semantics); everything else is walked + via Diff.flatten and applied as plain Sets with ancestor entries + shadowing their descendants. See _connectDiffFeed for the full contract. + + Every value crossing from Source to Replica is passed through `wireCopy`, + a deep copy with cycle detection that simulates a network boundary - + proving the feed payloads are self-contained and not shared Luau + references. +]] + +local TableManager = require("../../TableManager") +local Diff = require("../../Diff") + +export type FeedMode = "signals" | "diff" + +export type OpLogEntry = { + Signal: string, + [string]: any, +} + +--// wireCopy //-- + +local function wireCopy(value: any, seen: { [any]: any }?): any + if type(value) ~= "table" then + return value + end + + local memo = seen or {} + if memo[value] ~= nil then + return memo[value] + end + + local copy = {} + memo[value] = copy + for k, v in value do + copy[wireCopy(k, memo)] = wireCopy(v, memo) + end + return copy +end + +--// deepEqual //-- + +local function deepEqual(a: any, b: any): boolean + if a == b then + return true + end + if type(a) ~= "table" or type(b) ~= "table" then + return false + end + if #a ~= #b then + return false + end + for k, v in a do + if not deepEqual(v, b[k]) then + return false + end + end + for k in b do + if a[k] == nil then + return false + end + end + return true +end + +-- Human-readable path label for diagnostics. tostrings every segment so +-- non-string keys (numbers, booleans) are safe to join. +local function describePath(path: { any }): string + local parts = table.create(#path) + for i, segment in path do + parts[i] = tostring(segment) + end + return table.concat(parts, ".") +end + +local function isArrayLike(value: any): boolean + if type(value) ~= "table" then + return false + end + local count = 0 + for _ in value do + count += 1 + end + return count == #value +end + +--// Harness //-- + +--[=[ + @class ReplicationHarness + @ignore + + Test helper for the replication-fidelity spec. +]=] +local ReplicationHarness = {} +ReplicationHarness.__index = ReplicationHarness + +export type ReplicationHarness = typeof(setmetatable( + {} :: { + Source: any, + Replica: any, + _feedMode: FeedMode, + _connections: { any }, + _opLog: { OpLogEntry }, + _replicaOpLog: { OpLogEntry }, + _applyErrors: { { Op: string, Error: string } }, + }, + ReplicationHarness +)) + +-- `sourceConfig` (optional) is passed to the SOURCE TableManager only; the +-- replica is always config-free, like a client that trusts the wire. +function ReplicationHarness.new(sourceInitialData: any, feedMode: FeedMode?, sourceConfig: any?): ReplicationHarness + local self = setmetatable({}, ReplicationHarness) :: any + + self.Source = TableManager.new(sourceInitialData, sourceConfig) + self.Replica = TableManager.new(wireCopy(sourceInitialData)) + self._feedMode = feedMode or "signals" + self._connections = {} + self._opLog = {} + self._replicaOpLog = {} + self._applyErrors = {} + + return self +end + +--[[ + Runs one replica apply, recording (instead of swallowing) any error. + + Feed handlers run in signal-handler threads, where an uncaught error kills + only that one invocation and is merely printed - it does NOT fail the test. + That hid a real defect class: a redundant or late event that errors on + apply, while the replica happens to already hold the right state (e.g. a + child-removal event delivered after its parent was replaced by a scalar). + Every apply goes through here, and IsConverged() reports false if ANY apply + errored, so state-level tests also act as apply-error detectors. +]] +function ReplicationHarness._apply(self: ReplicationHarness, opDescription: string, applyFn: () -> ()) + local ok, err = pcall(applyFn) + if not ok then + table.insert(self._applyErrors, { Op = opDescription, Error = tostring(err) }) + end +end + +--- Returns the errors recorded while applying feed events to the replica. +function ReplicationHarness.ApplyErrors(self: ReplicationHarness): { { Op: string, Error: string } } + return self._applyErrors +end + +--// Replica echo recorder //-- + +--[[ + Records the events the REPLICA emits while the feed is applied to it, so + tests can assert the replica re-emits an equivalent event stream ("echo"). + Only the four state-bearing signals are recorded; KeyAdded/KeyRemoved/ + KeyChanged are derivable diagnostics on both sides. +]] +function ReplicationHarness._connectReplicaRecorders(self: ReplicationHarness) + const replica = self.Replica + const connections = self._connections + const replicaOpLog = self._replicaOpLog + + const valueChangedConn = replica.ValueChanged:Connect(function(path, newValue, oldValue) + table.insert( + replicaOpLog, + { Signal = "ValueChanged", Path = wireCopy(path), New = newValue, Old = oldValue } :: OpLogEntry + ) + end) + table.insert(connections, valueChangedConn) + + const arrayInsertedConn = replica.ArrayInserted:Connect(function(path, index, newValue) + table.insert( + replicaOpLog, + { Signal = "ArrayInserted", Path = wireCopy(path), Index = index, New = newValue } :: OpLogEntry + ) + end) + table.insert(connections, arrayInsertedConn) + + const arrayRemovedConn = replica.ArrayRemoved:Connect(function(path, index, oldValue) + table.insert( + replicaOpLog, + { Signal = "ArrayRemoved", Path = wireCopy(path), Index = index, Old = oldValue } :: OpLogEntry + ) + end) + table.insert(connections, arrayRemovedConn) + + const arraySetConn = replica.ArraySet:Connect(function(path, index, newValue, oldValue) + table.insert( + replicaOpLog, + { Signal = "ArraySet", Path = wireCopy(path), Index = index, New = newValue, Old = oldValue } :: OpLogEntry + ) + end) + table.insert(connections, arraySetConn) +end + +--// Feed: signals //-- + +function ReplicationHarness._connectSignalFeed(self: ReplicationHarness) + const source = self.Source + const replica = self.Replica + const connections = self._connections + const opLog = self._opLog + + const valueChangedConn = source.ValueChanged:Connect(function(path, newValue, oldValue) + const wirePath = wireCopy(path) + const opLogEntry: OpLogEntry = { Signal = "ValueChanged", Path = wirePath, New = newValue, Old = oldValue } + table.insert(opLog, opLogEntry) + + if #wirePath == 0 then + -- Only reachable via ForceNotify(root) where old == new; nothing to apply. + return + end + + self:_apply(`ValueChanged {describePath(wirePath)}`, function() + if newValue == nil then + replica:Set(wirePath, nil, true) + else + replica:Set(wirePath, wireCopy(newValue), true) + end + end) + end) + table.insert(connections, valueChangedConn) + + const arrayInsertedConn = source.ArrayInserted:Connect(function(path, index, newValue) + const wirePath = wireCopy(path) + const opLogEntry: OpLogEntry = { Signal = "ArrayInserted", Path = wirePath, Index = index, New = newValue } + table.insert(opLog, opLogEntry) + + self:_apply(`ArrayInserted {describePath(wirePath)}@{index}`, function() + replica:ArrayInsert(wirePath, index, wireCopy(newValue)) + end) + end) + table.insert(connections, arrayInsertedConn) + + const arrayRemovedConn = source.ArrayRemoved:Connect(function(path, index, oldValue) + const wirePath = wireCopy(path) + const opLogEntry: OpLogEntry = { Signal = "ArrayRemoved", Path = wirePath, Index = index, Old = oldValue } + table.insert(opLog, opLogEntry) + + self:_apply(`ArrayRemoved {describePath(wirePath)}@{index}`, function() + replica:ArrayRemove(wirePath, index) + end) + end) + table.insert(connections, arrayRemovedConn) + + const arraySetConn = source.ArraySet:Connect(function(path, index, newValue, oldValue) + const wirePath = wireCopy(path) + const opLogEntry: OpLogEntry = + { Signal = "ArraySet", Path = wirePath, Index = index, New = newValue, Old = oldValue } + table.insert(opLog, opLogEntry) + + self:_apply(`ArraySet {describePath(wirePath)}@{index}`, function() + const elementPath = table.clone(wirePath) + table.insert(elementPath, index) + replica:Set(elementPath, wireCopy(newValue), true) + end) + end) + table.insert(connections, arraySetConn) + + -- Diagnostics only - NOT applied. Redundant with ValueChanged for + -- replication; applying these too would double-apply dictionary changes. + for _, signalName in { "KeyAdded", "KeyRemoved", "KeyChanged" } do + const conn = (source[signalName] :: any):Connect(function(path, ...) + const opLogEntry: OpLogEntry = { Signal = signalName, Path = wireCopy(path), Args = table.pack(...) } + table.insert(opLog, opLogEntry) + end) + table.insert(connections, conn) + end +end + +--// Feed: diff //-- + +-- Builds a type-qualified string key for a path so prefix lookups can't be +-- spoofed by tostring collisions (e.g. 1 vs "1"). +local function diffEntryPathKey(path: { any }): string + local parts = table.create(#path) + for i, segment in path do + parts[i] = `{typeof(segment)}:{tostring(segment)}` + end + return table.concat(parts, "\0") +end + +--[[ + Diff-feed consumer contract (mirrors what a TableReplicator rebuild must do): + + 1. Array operations arrive tagged with `metadata.ArrayOp` and MUST be + applied as array ops: ArrayInserted/ArrayRemoved SHIFT later elements, + ArraySet does not. A flattened "removed" entry at a numeric leaf is + otherwise indistinguishable from an in-place nil write (a hole). + 2. Everything else is flattened and applied as plain (non-shifting) Sets: + `added`/`changed` set `entry.new`, `removed` sets nil. + 3. Within one delivery, an entry whose path extends another entry's path is + SHADOWED and skipped: the ancestor entry's `new` carries the whole + subtree (e.g. a scalar<->table transition), so it is authoritative and + applying descendants too could target paths that no longer hold tables. + Surviving entries have no ancestor/descendant relation, so apply order + does not matter. +]] +function ReplicationHarness._connectDiffFeed(self: ReplicationHarness) + local source = self.Source + local replica = self.Replica + local opLog = self._opLog + + local conn = source:OnChange({}, function(_newValue, _oldValue, metadata) + if metadata == nil or metadata.Diff ~= nil then + -- Only the once-per-operation root ancestor firing carries the + -- full OriginDiff tree; skip the (non-existent at root) leaf case. + return + end + + local originPath = wireCopy(metadata.OriginPath) + table.insert( + opLog, + { + Signal = "Diff", + OriginPath = originPath, + OriginDiff = metadata.OriginDiff, + ArrayOp = metadata.ArrayOp, + } :: OpLogEntry + ) + + local arrayOp = metadata.ArrayOp + if arrayOp then + self:_apply(`{arrayOp.Kind} {describePath(originPath)}`, function() + local arrayPath = table.clone(originPath) + table.remove(arrayPath) + if arrayOp.Kind == "ArrayInserted" then + replica:ArrayInsert(arrayPath, arrayOp.Index, wireCopy(metadata.OriginDiff.new)) + elseif arrayOp.Kind == "ArrayRemoved" then + replica:ArrayRemove(arrayPath, arrayOp.Index) + else -- ArraySet: in-place element overwrite, no shift + replica:Set(originPath, wireCopy(metadata.OriginDiff.new), true) + end + end) + return + end + + local entries = Diff.flatten(metadata.OriginDiff, originPath) + + local entryPathKeys: { [string]: boolean } = {} + for _, entry in entries do + entryPathKeys[diffEntryPathKey(entry.path)] = true + end + local function isShadowed(path: { any }): boolean + for i = 1, #path - 1 do + local prefix = table.create(i) + table.move(path, 1, i, 1, prefix) + if entryPathKeys[diffEntryPathKey(prefix)] then + return true + end + end + return false + end + + for _, entry in entries do + if #entry.path == 0 or isShadowed(entry.path) then + continue + end + self:_apply(`Diff:{entry.type} {describePath(entry.path)}`, function() + if entry.type == "removed" then + replica:Set(wireCopy(entry.path), nil, true) + else + replica:Set(wireCopy(entry.path), wireCopy(entry.new), true) + end + end) + end + end) + + table.insert(self._connections, conn) +end + +--// Public API //-- + +function ReplicationHarness.Connect(self: ReplicationHarness) + if self._feedMode == "signals" then + self:_connectSignalFeed() + elseif self._feedMode == "diff" then + self:_connectDiffFeed() + else + error(`ReplicationHarness: unknown feed mode "{self._feedMode}"`) + end + self:_connectReplicaRecorders() +end + +--[[ + Re-seeds the replica from a deep copy of the source's CURRENT state, + modelling a late-joining client that receives a snapshot before streaming + begins. Call before Connect(); pre-connect source mutations are otherwise + never delivered. +]] +function ReplicationHarness.ResyncReplica(self: ReplicationHarness) + local snapshot = wireCopy(self.Source.Raw) + + -- Collect stale keys before mutating; removing keys while iterating the + -- live table would be unsafe. + local staleKeys = {} + for key in self.Replica.Raw do + if snapshot[key] == nil then + table.insert(staleKeys, key) + end + end + for _, key in staleKeys do + self.Replica:Set({ key }, nil, true) + end + + for key, value in snapshot do + self.Replica:Set({ key }, value, true) + end + -- Anything the replica emitted while re-seeding is join-time noise, not echo. + table.clear(self._replicaOpLog) +end + +--[[ + Runs one source operation, then reports whether the replica converged. + Lets multi-operation tests prove state equality after EVERY step rather + than only at the end. +]] +function ReplicationHarness.Step(self: ReplicationHarness, fn: () -> ()): boolean + fn() + return self:IsConverged() +end + +function ReplicationHarness.OpCount(self: ReplicationHarness): number + return #self._opLog +end + +--[[ + Normalizes an op-log entry to its state-bearing core so the source and + replica streams can be compared even though a replica can only re-emit + through the public API: + - ValueChanged(path, new) -> set(path, new) + - ArraySet(path, i, new) -> set(path..i, new) (a replica applies + ArraySet via :Set, which re-emits ValueChanged at the element path) + - ArrayInserted / ArrayRemoved -> kept as-is + - diagnostics (Key*) -> dropped (derivable on both sides) +]] +local function normalizeForEcho(opLog: { OpLogEntry }): { any } + local normalized: { any } = {} + for _, entry in opLog do + if entry.Signal == "ValueChanged" then + table.insert(normalized, { Kind = "set", Path = entry.Path, Value = entry.New }) + elseif entry.Signal == "ArraySet" then + local elementPath = table.clone(entry.Path) + table.insert(elementPath, entry.Index) + table.insert(normalized, { Kind = "set", Path = elementPath, Value = entry.New }) + elseif entry.Signal == "ArrayInserted" then + table.insert(normalized, { Kind = "insert", Path = entry.Path, Index = entry.Index, Value = entry.New }) + elseif entry.Signal == "ArrayRemoved" then + table.insert(normalized, { Kind = "remove", Path = entry.Path, Index = entry.Index }) + end + end + return normalized +end + +--[[ + True when the replica re-emitted the same normalized event stream, in the + same order, as the source produced. Only meaningful in "signals" mode: the + diff feed's source log holds Diff entries, which normalize to nothing. +]] +function ReplicationHarness.EchoMatches(self: ReplicationHarness): boolean + local sourceStream = normalizeForEcho(self._opLog) + local replicaStream = normalizeForEcho(self._replicaOpLog) + + local matches = deepEqual(sourceStream, replicaStream) + if not matches and ReplicationHarness.DEBUG then + print(self:Diagnose()) + end + return matches +end + +--[[ + Deeply serializes a value to a stable, sorted string for debug output. + Sorts keys so Source and Replica dumps are directly comparable line-by-line. +]] +local function serialize(value: any, indent: string?): string + indent = indent or "" + if type(value) ~= "table" then + if type(value) == "string" then + return `"{value}"` + end + return tostring(value) + end + + local keys = {} + for k in value do + table.insert(keys, k) + end + table.sort(keys, function(a, b) + return tostring(a) < tostring(b) + end) + + if #keys == 0 then + return "{}" + end + + local childIndent = (indent :: string) .. " " + local parts = {} + for _, k in keys do + local keyStr = if type(k) == "string" then `"{k}"` else tostring(k) + table.insert(parts, `{childIndent}[{keyStr}] = {serialize(value[k], childIndent)}`) + end + return "{\n" .. table.concat(parts, ",\n") .. "\n" .. (indent :: string) .. "}" +end + +--[[ + Serializes one op-log entry to a single readable line. +]] +local function serializeOp(entry: OpLogEntry): string + local parts = { entry.Signal } + for k, v in entry do + if k ~= "Signal" then + table.insert(parts, `{k}={serialize(v)}`) + end + end + return table.concat(parts, " ") +end + +-- Set to true to make IsConverged() dump full Source/Replica state + op log +-- whenever it returns false. Only the failing tests print, so this stays quiet +-- for the rest of the suite. +ReplicationHarness.DEBUG = true + +function ReplicationHarness.IsConverged(self: ReplicationHarness): boolean + -- Apply errors fail convergence even when the final state happens to match: + -- an event a real consumer would crash on is not a healthy feed. + local converged = #self._applyErrors == 0 and deepEqual(self.Source.Raw, self.Replica.Raw) + if not converged and ReplicationHarness.DEBUG then + print(self:Diagnose()) + end + return converged +end + +function ReplicationHarness.Diagnose(self: ReplicationHarness): string + local lines = { + `\n===== ReplicationHarness divergence (feed: {self._feedMode}) =====`, + `--- Op log ({#self._opLog} entries, in apply order) ---`, + } + for i, entry in self._opLog do + table.insert(lines, ` {i}. {serializeOp(entry)}`) + end + table.insert(lines, `--- Replica echo log ({#self._replicaOpLog} entries) ---`) + for i, entry in self._replicaOpLog do + table.insert(lines, ` {i}. {serializeOp(entry)}`) + end + table.insert(lines, `--- Apply errors ({#self._applyErrors}) ---`) + for i, applyError in self._applyErrors do + table.insert(lines, ` {i}. [{applyError.Op}] {applyError.Error}`) + end + table.insert(lines, `--- Source.Raw ---`) + table.insert(lines, serialize(self.Source.Raw)) + table.insert(lines, `--- Replica.Raw ---`) + table.insert(lines, serialize(self.Replica.Raw)) + table.insert(lines, "================================================\n") + return table.concat(lines, "\n") +end + +function ReplicationHarness.Destroy(self: ReplicationHarness) + for _, conn in self._connections do + conn:Disconnect() + end + self.Source:Destroy() + self.Replica:Destroy() +end + +ReplicationHarness.wireCopy = wireCopy +ReplicationHarness.deepEqual = deepEqual +ReplicationHarness.isArrayLike = isArrayLike + +return ReplicationHarness diff --git a/lib/tablemanager2/src/Tests/ListenerRegistry.spec.luau b/lib/tablemanager2/src/Tests/ListenerRegistry.spec.luau new file mode 100644 index 00000000..e650fee1 --- /dev/null +++ b/lib/tablemanager2/src/Tests/ListenerRegistry.spec.luau @@ -0,0 +1,893 @@ +--!strict +--[[ + Unit tests for ListenerRegistry_new to verify: + - Listener registration and disconnection + - FireListenersExact (exact path matching) + - ListenDepth / ListenDepthStyle filtering + - Once auto-disconnect + - Multiple listeners on same path + - Event data structure handling +]] + +return function(t: tiniest) + local ListenerRegistry = require("../ListenerRegistry") + local IsDeferred = require("../IsDeferred") + assert(IsDeferred() == false, "Tests assume deferred event dispatch is disabled") + + local test = t.test + local describe = t.describe + local expect = t.expect + + local function fireAndFlush(registry: any, eventType, path, eventData) + registry:FireListenersExact(eventType, path, eventData) + task.wait() + end + + describe("Listener Registration", function() + test("should register a listener and return a connection", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local connection = registry:RegisterListener("ValueChanged", { "player", "health" }, function() end) + + expect(connection).exists() + expect(connection.Connected).is_true() + + registry:Destroy() + end) + + test("should allow disconnecting a listener", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local connection = registry:RegisterListener("ValueChanged", { "player", "health" }, function() end) + connection:Disconnect() + + expect(connection.Connected).never_is_true() + + registry:Destroy() + end) + + test("should not fire disconnected listeners", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local fireCount = 0 + local connection = registry:RegisterListener("ValueChanged", { "player", "health" }, function() + fireCount += 1 + end) + + connection:Disconnect() + + fireAndFlush(registry, "ValueChanged", { "player", "health" }, { + NewValue = 50, + OldValue = 100, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(fireCount).is(0) + + registry:Destroy() + end) + end) + + describe("FireListenersExact", function() + test("should fire listener at exact matching path", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local fired = false + registry:RegisterListener("ValueChanged", { "player", "health" }, function() + fired = true + end) + + fireAndFlush(registry, "ValueChanged", { "player", "health" }, { + NewValue = 50, + OldValue = 100, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(fired).is_true() + + registry:Destroy() + end) + + test("should NOT fire listener at parent path", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local fired = false + registry:RegisterListener("ValueChanged", { "player" }, function() + fired = true + end) + + -- Fire at child path - should NOT fire parent listener + fireAndFlush(registry, "ValueChanged", { "player", "health" }, { + NewValue = 50, + OldValue = 100, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(fired).never_is_true() + + registry:Destroy() + end) + + test("should NOT fire listener at child path", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local fired = false + registry:RegisterListener("ValueChanged", { "player", "health" }, function() + fired = true + end) + + -- Fire at parent path - should NOT fire child listener + fireAndFlush(registry, "ValueChanged", { "player" }, { + NewValue = { health = 50 }, + OldValue = { health = 100 }, + Metadata = { + Diff = nil, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(fired).never_is_true() + + registry:Destroy() + end) + + test("should fire multiple listeners on same path", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local count1 = 0 + local count2 = 0 + + registry:RegisterListener("ValueChanged", { "player", "health" }, function() + count1 += 1 + end) + + registry:RegisterListener("ValueChanged", { "player", "health" }, function() + count2 += 1 + end) + + fireAndFlush(registry, "ValueChanged", { "player", "health" }, { + NewValue = 50, + OldValue = 100, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(count1).is(1) + expect(count2).is(1) + + registry:Destroy() + end) + end) + + describe("ListenDepth Filtering", function() + test("nil depth (default) fires for ancestor notifications at any depth", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local fired = false + registry:RegisterListener("ValueChanged", { "player" }, function() + fired = true + end) -- depth=nil by default + + -- Ancestor notification: change originated at ["player", "health"] (depth 1 from listener) + fireAndFlush(registry, "ValueChanged", { "player" }, { + NewValue = nil, + OldValue = nil, + Metadata = { + Diff = nil, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(fired).is_true() + + registry:Destroy() + end) + + test("depth=0 does NOT fire for ancestor notifications", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local fired = false + registry:RegisterListener("ValueChanged", { "player" }, function() + fired = true + end, { ListenDepth = 0 }) + + -- Ancestor notification: change at ["player", "health"] is depth 1 from listener + fireAndFlush(registry, "ValueChanged", { "player" }, { + NewValue = nil, + OldValue = nil, + Metadata = { + Diff = nil, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(fired).never_is_true() + + registry:Destroy() + end) + + test("depth=0 DOES fire for direct changes (relativeDepth=0)", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local fired = false + registry:RegisterListener("ValueChanged", { "player" }, function() + fired = true + end, { ListenDepth = 0 }) + + -- Direct change at the listener's exact path (Diff present) + fireAndFlush(registry, "ValueChanged", { "player" }, { + NewValue = { health = 50 }, + OldValue = { health = 100 }, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "player" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(fired).is_true() + + registry:Destroy() + end) + + test("depth=1 style='<=' fires for depth-1 ancestor, not depth-2", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local fired = false + registry:RegisterListener("ValueChanged", { "player" }, function() + fired = true + end, { ListenDepth = 1, ListenDepthStyle = "<=" }) + + -- Depth 1 ancestor: origin at ["player", "health"] + fireAndFlush(registry, "ValueChanged", { "player" }, { + NewValue = nil, + OldValue = nil, + Metadata = { + Diff = nil, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(fired).is_true() + + fired = false + + -- Depth 2 ancestor: origin at ["player", "stats", "hp"] should NOT fire + fireAndFlush(registry, "ValueChanged", { "player" }, { + NewValue = nil, + OldValue = nil, + Metadata = { + Diff = nil, + OriginPath = { "player", "stats", "hp" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(fired).never_is_true() + + registry:Destroy() + end) + + test("depth=1 style='==' fires at exactly depth 1, not depth 0", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local fired = false + registry:RegisterListener("ValueChanged", { "player" }, function() + fired = true + end, { ListenDepth = 1, ListenDepthStyle = "==" }) + + -- Direct change at listener path (depth 0) should NOT fire + fireAndFlush(registry, "ValueChanged", { "player" }, { + NewValue = { health = 50 }, + OldValue = { health = 100 }, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "player" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(fired).never_is_true() + + -- Depth 1 ancestor (origin at ["player", "health"]) SHOULD fire + fireAndFlush(registry, "ValueChanged", { "player" }, { + NewValue = nil, + OldValue = nil, + Metadata = { + Diff = nil, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(fired).is_true() + + registry:Destroy() + end) + end) + + describe("Once Option", function() + test("fires exactly once then auto-disconnects", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local fireCount = 0 + local conn = registry:RegisterListener("ValueChanged", { "player", "health" }, function() + fireCount += 1 + end, { Once = true }) + + local eventData = { + NewValue = 50, + OldValue = 100, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + } + + fireAndFlush(registry, "ValueChanged", { "player", "health" }, eventData) + fireAndFlush(registry, "ValueChanged", { "player", "health" }, eventData) + + expect(fireCount).is(1) + expect(conn.Connected).never_is_true() + + registry:Destroy() + end) + + test("Connected is false immediately after the first fire", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local connAfterFire: any = nil + local conn: any + conn = registry:RegisterListener("ValueChanged", { "player", "health" }, function() + connAfterFire = conn + end, { Once = true }) + + fireAndFlush(registry, "ValueChanged", { "player", "health" }, { + NewValue = 50, + OldValue = 100, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + }) + + -- conn.Connected was already false when the callback ran + expect(connAfterFire).exists() + expect(connAfterFire.Connected).never_is_true() + + registry:Destroy() + end) + + test("re-entrant fire does not double-fire a Once listener", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local fireCount = 0 + local eventData: any = { + NewValue = 50, + OldValue = 100, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + } + + registry:RegisterListener("ValueChanged", { "player", "health" }, function() + fireCount += 1 + -- Re-entrant: fire the same path again inside the callback + registry:FireListenersExact("ValueChanged", { "player", "health" }, eventData) + end, { Once = true }) + + fireAndFlush(registry, "ValueChanged", { "player", "health" }, eventData) + + expect(fireCount).is(1) + + registry:Destroy() + end) + end) + + describe("Event Data Handling", function() + test("should pass correct arguments for ValueChanged", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local capturedNew = nil + local capturedOld = nil + local capturedMetadata = nil + + registry:RegisterListener("ValueChanged", { "player", "health" }, function(newValue, oldValue, metadata) + capturedNew = newValue + capturedOld = oldValue + capturedMetadata = metadata + end) + + local testMetadata = { + Diff = { type = "changed" }, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + } + + fireAndFlush(registry, "ValueChanged", { "player", "health" }, { + NewValue = 50, + OldValue = 100, + Metadata = testMetadata, + }) + + expect(capturedNew).is(50) + expect(capturedOld).is(100) + expect(capturedMetadata).is(testMetadata) + + registry:Destroy() + end) + + test("should pass correct arguments for KeyAdded", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local capturedValue = nil + local capturedMetadata = nil + + registry:RegisterListener("KeyAdded", { "player" }, function(key, value, metadata) + capturedValue = value + capturedMetadata = metadata + end) + + local testMetadata = { + Diff = { type = "added" }, + OriginPath = { "player", "mana" }, + OriginDiff = { type = "added" }, + } + + fireAndFlush(registry, "KeyAdded", { "player" }, { + NewValue = 50, + Key = "mana", + Metadata = testMetadata, + }) + + expect(capturedValue).is(50) + expect(capturedMetadata).is(testMetadata) + + registry:Destroy() + end) + + test("should pass correct arguments for ArrayInserted", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local capturedIndex = nil + local capturedValue = nil + local capturedMetadata = nil + + registry:RegisterListener("ArrayInserted", { "items", 3 }, function(index, value, metadata) + capturedIndex = index + capturedValue = value + capturedMetadata = metadata + end) + + local testMetadata = { + Diff = { type = "added" }, + OriginPath = { "items", 3 }, + OriginDiff = { type = "added" }, + } + + fireAndFlush(registry, "ArrayInserted", { "items", 3 }, { + Index = 3, + NewValue = "Sword", + Metadata = testMetadata, + }) + + expect(capturedIndex).is(3) + expect(capturedValue).is("Sword") + expect(capturedMetadata).is(testMetadata) + + registry:Destroy() + end) + end) + + describe("Edge Cases", function() + test("should handle root path (empty array)", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local fired = false + registry:RegisterListener("ValueChanged", {}, function() + fired = true + end) + + fireAndFlush(registry, "ValueChanged", {}, { + NewValue = { player = { health = 50 } }, + OldValue = { player = { health = 100 } }, + Metadata = { + Diff = nil, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(fired).is_true() + + registry:Destroy() + end) + + test("should handle multiple disconnections safely", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local connection = registry:RegisterListener("ValueChanged", { "player" }, function() end) + + connection:Disconnect() + connection:Disconnect() -- Should not error + + expect(connection.Connected).never_is_true() + + registry:Destroy() + end) + + test("should clean up all listeners on Destroy", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local conn1 = registry:RegisterListener("ValueChanged", { "player" }, function() end) + local conn2 = registry:RegisterListener("KeyAdded", { "player" }, function() end) + + registry:Destroy() + + expect(conn1.Connected).never_is_true() + expect(conn2.Connected).never_is_true() + end) + end) + + describe("Wildcard Listeners", function() + test("should fire a wildcard listener for a matching literal segment", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local matches: { any }? = nil + registry:RegisterListener("ValueChanged", { "Players", "*", "Health" }, function(_, _, metadata) + matches = metadata.WildcardMatches + end) + + fireAndFlush(registry, "ValueChanged", { "Players", "p123", "Health" }, { + NewValue = 90, + OldValue = 100, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "Players", "p123", "Health" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(matches).exists() + expect((matches :: { any })[1]).is("p123") + + registry:Destroy() + end) + + test("should fire both a literal listener and a wildcard listener for the same change", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local literalFired, wildcardFired = false, false + registry:RegisterListener("ValueChanged", { "Players", "p123", "Health" }, function() + literalFired = true + end) + registry:RegisterListener("ValueChanged", { "Players", "*", "Health" }, function() + wildcardFired = true + end) + + fireAndFlush(registry, "ValueChanged", { "Players", "p123", "Health" }, { + NewValue = 90, + OldValue = 100, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "Players", "p123", "Health" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(literalFired).is_true() + expect(wildcardFired).is_true() + + registry:Destroy() + end) + + test("should not fire a wildcard listener for a non-matching path", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local fired = false + registry:RegisterListener("ValueChanged", { "Players", "*", "Health" }, function() + fired = true + end) + + fireAndFlush(registry, "ValueChanged", { "Players", "p123", "Mana" }, { + NewValue = 10, + OldValue = 20, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "Players", "p123", "Mana" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(fired).never_is_true() + + registry:Destroy() + end) + + test("should accumulate matches left-to-right for multiple wildcard segments", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local matches: { any }? = nil + registry:RegisterListener("ValueChanged", { "Players", "*", "Inventory", "*" }, function(_, _, metadata) + matches = metadata.WildcardMatches + end) + + fireAndFlush(registry, "ValueChanged", { "Players", "p123", "Inventory", "item7" }, { + NewValue = "Sword", + OldValue = nil, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "Players", "p123", "Inventory", "item7" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(matches).exists() + local m = matches :: { any } + expect(m[1]).is("p123") + expect(m[2]).is("item7") + + registry:Destroy() + end) + + test("should fire a wildcard ancestor listener when a descendant changes", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local matches: { any }? = nil + registry:RegisterListener("ValueChanged", { "Players", "*" }, function(_, _, metadata) + matches = metadata.WildcardMatches + end) + + -- Ancestor notification: Diff == nil, OriginPath deeper than the listener path + fireAndFlush(registry, "ValueChanged", { "Players", "p456" }, { + NewValue = nil, + OldValue = nil, + Metadata = { + Diff = nil, + OriginPath = { "Players", "p456", "Health" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(matches).exists() + expect((matches :: { any })[1]).is("p456") + + registry:Destroy() + end) + + test("Once on a wildcard listener fires once total, not once per matching key", function() + local registry = ListenerRegistry.new { DebugMode = false } + + local fireCount = 0 + registry:RegisterListener("ValueChanged", { "Players", "*", "Health" }, function() + fireCount += 1 + end, { Once = true }) + + fireAndFlush(registry, "ValueChanged", { "Players", "p123", "Health" }, { + NewValue = 90, + OldValue = 100, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "Players", "p123", "Health" }, + OriginDiff = { type = "changed" }, + }, + }) + + fireAndFlush(registry, "ValueChanged", { "Players", "p456", "Health" }, { + NewValue = 70, + OldValue = 80, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "Players", "p456", "Health" }, + OriginDiff = { type = "changed" }, + }, + }) + + expect(fireCount).is(1) + + registry:Destroy() + end) + end) + + describe("Dispatch Config", function() + test("defaults to immediate dispatch when no config is provided", function() + local registry = ListenerRegistry.new() + local callOrder = {} + + registry:RegisterListener("ValueChanged", { "player", "health" }, function() + table.insert(callOrder, "listener") + end) + + table.insert(callOrder, "before-fire") + registry:FireListenersExact("ValueChanged", { "player", "health" }, { + NewValue = 50, + OldValue = 100, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + }) + table.insert(callOrder, "after-fire") + + task.wait() + + expect(callOrder[1]).is("before-fire") + expect(callOrder[2]).is("listener") + expect(callOrder[3]).is("after-fire") + + registry:Destroy() + end) + + test("fires deferred when FireMode=Deferred", function() + local registry = ListenerRegistry.new { DebugMode = false, FireMode = "Deferred" } + local callOrder = {} + + registry:RegisterListener("ValueChanged", { "player", "health" }, function() + table.insert(callOrder, "listener") + end) + + table.insert(callOrder, "before-fire") + registry:FireListenersExact("ValueChanged", { "player", "health" }, { + NewValue = 50, + OldValue = 100, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + }) + table.insert(callOrder, "after-fire") + + task.wait() + + expect(callOrder[1]).is("before-fire") + expect(callOrder[2]).is("after-fire") + expect(callOrder[3]).is("listener") + + registry:Destroy() + end) + + test("collapses repeated fires into one call when FireMode=Coalesced", function() + local registry = ListenerRegistry.new { DebugMode = false, FireMode = "Coalesced" } + local fireCount = 0 + local lastNewValue: number? = nil + + registry:RegisterListener("ValueChanged", { "player", "health" }, function(newValue: number) + fireCount += 1 + lastNewValue = newValue + end) + + local function fireHealthChange(newValue: number, oldValue: number) + registry:FireListenersExact("ValueChanged", { "player", "health" }, { + NewValue = newValue, + OldValue = oldValue, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + }) + end + + -- Two fires before yielding should collapse into a single callback + -- invocation carrying the LATEST data. + fireHealthChange(75, 100) + fireHealthChange(50, 75) + + expect(fireCount).is(0) -- nothing has run synchronously + + task.wait() + + expect(fireCount).is(1) + expect(lastNewValue).is(50) + + registry:Destroy() + end) + + test("resolves Bindable to either Immediate or Deferred ordering", function() + local registry = ListenerRegistry.new { DebugMode = false, FireMode = "Bindable" } + local callOrder = {} + + registry:RegisterListener("ValueChanged", { "player", "health" }, function() + table.insert(callOrder, "listener") + end) + + table.insert(callOrder, "before-fire") + registry:FireListenersExact("ValueChanged", { "player", "health" }, { + NewValue = 50, + OldValue = 100, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + }) + table.insert(callOrder, "after-fire") + + task.wait() + + -- "Bindable" mirrors the engine's actual SignalBehavior, so either + -- ordering is valid depending on the environment; what matters is + -- that the listener ran exactly once. + expect(#callOrder).is(3) + expect(table.find(callOrder, "listener") ~= nil).is(true) + + registry:Destroy() + end) + end) + + describe("Thread Safety", function() + test("listeners that yield do not prevent all callbacks from completing", function() + local registry = ListenerRegistry.new { DebugMode = false } + local completions = {} + + registry:RegisterListener("ValueChanged", { "player", "health" }, function() + task.wait(0.02) + table.insert(completions, "slow") + end) + + registry:RegisterListener("ValueChanged", { "player", "health" }, function() + table.insert(completions, "fast") + end) + + registry:FireListenersExact("ValueChanged", { "player", "health" }, { + NewValue = 50, + OldValue = 100, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + }) + + task.wait(0.05) + expect(#completions).is(2) + + registry:Destroy() + end) + + test("destroy disconnects consumed once connections", function() + local registry = ListenerRegistry.new { DebugMode = false, FireMode = "Deferred" } + local onceConnection = registry:RegisterListener("ValueChanged", { "player", "health" }, function() end, { + Once = true, + }) + + registry:FireListenersExact("ValueChanged", { "player", "health" }, { + NewValue = 50, + OldValue = 100, + Metadata = { + Diff = { type = "changed" }, + OriginPath = { "player", "health" }, + OriginDiff = { type = "changed" }, + }, + }) + + registry:Destroy() + expect(onceConnection.Connected).never_is_true() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/MemoryLeaks.spec.luau b/lib/tablemanager2/src/Tests/MemoryLeaks.spec.luau new file mode 100644 index 00000000..1ea2bc7a --- /dev/null +++ b/lib/tablemanager2/src/Tests/MemoryLeaks.spec.luau @@ -0,0 +1,317 @@ +--!strict +--[[ + Memory-leak regression tests for TableManager2. + + Layer 1 (deterministic): asserts the library releases its internal STRONG + references (proxy-graph maps, listener trees, batch state, dispatch queue). + If no strong references remain, the values are GC-eligible by construction. + + Layer 2 (best-effort): weak-probe collectability checks driven by allocation + pressure (Luau has no forced GC; see Helpers/GcHelpers.luau). +]] + +return function(t: tiniest) + local TableManager = require("../TableManager") + local ListenerRegistry = require("../ListenerRegistry") + local GcHelpers = require("./Helpers/GcHelpers") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("ProxyManager bookkeeping eviction", function() + test("Set(path, nil) evicts proxy bookkeeping for the removed subtree", function() + local data = { a = { b = { c = 1 } } } + local tm = TableManager.new(data) :: any + local pm = tm._proxyManager + + local aTable = data.a + local bTable = data.a.b + local _touch = tm.Proxy.a.b.c -- force proxy creation for `a` and `a.b` + + expect(pm._originalToProxy[aTable]).exists() + expect(pm._originalToProxy[bTable]).exists() + expect(pm._proxiesByParent[aTable]).exists() + + tm:Set("a", nil) + + expect(pm._originalToProxy[aTable]).never_exists() + expect(pm._originalToProxy[bTable]).never_exists() + expect(pm._proxiesByParent[aTable]).never_exists() + expect(pm._proxiesByParent[bTable]).never_exists() + expect(pm._proxiesByParent[data]).never_exists() + + tm:Destroy() + end) + + test("overwriting a table evicts the old subtree and tracks the new one", function() + local data = { a = { old = true } } + local tm = TableManager.new(data) :: any + local pm = tm._proxyManager + + local oldTable = data.a + local _touchOld = tm.Proxy.a + + tm.Proxy.a = { new = true } + local newTable = tm:Get("a") + local _touchNew = tm.Proxy.a + + expect(pm._originalToProxy[oldTable]).never_exists() + expect(pm._originalToProxy[newTable]).exists() + + tm:Destroy() + end) + + test("ArrayRemove and ArraySwapRemove evict removed element tables", function() + local e1 = { id = 1 } + local e2 = { id = 2 } + local e3 = { id = 3 } + local data = { items = { e1, e2, e3 } } + local tm = TableManager.new(data) :: any + local pm = tm._proxyManager + + local itemsArray = data.items + -- Touch all elements through the proxy so each gets tracked. + for _index, _element in tm.Proxy.items do + end + expect(pm._originalToProxy[e1]).exists() + expect(pm._proxiesByParent[itemsArray]).exists() + + tm:ArrayRemove("items", 1) + expect(pm._originalToProxy[e1]).never_exists() + expect(pm._originalToProxy[e2]).exists() -- shifted left, still live + + tm:ArraySwapRemove("items", 1) -- removes e2, backfills with e3 + expect(pm._originalToProxy[e2]).never_exists() + expect(pm._originalToProxy[e3]).exists() + + tm:Destroy() + end) + + test("add/read/remove churn does not grow the strong proxy maps", function() + local data = { items = {} } + local tm = TableManager.new(data) :: any + local pm = tm._proxyManager + + local _touch = tm.Proxy.items + local baselineOriginals = GcHelpers.CountKeys(pm._originalToProxy) + local baselineParents = GcHelpers.CountKeys(pm._proxiesByParent) + + for i = 1, 100 do + tm:ArrayInsert("items", { id = i }) + local _element = tm.Proxy.items[1] + tm:ArrayRemove("items", 1) + end + + expect(GcHelpers.CountKeys(pm._originalToProxy)) + .with_context(`baseline={baselineOriginals}`) + .is(baselineOriginals) + expect(GcHelpers.CountKeys(pm._proxiesByParent)) + .with_context(`baseline={baselineParents}`) + .is(baselineParents) + + tm:Destroy() + end) + + test("MoveTo keeps the moved table tracked (liveness guard)", function() + local data = { a = { v = 1 } } + local tm = TableManager.new(data) :: any + local pm = tm._proxyManager + + local aTable = data.a + local heldProxy = tm.Proxy.a + + tm:MoveTo("a", "b") + + expect(pm._originalToProxy[aTable]).exists() + -- The held proxy resolves to the new location; writes land there. + heldProxy.v = 2 + expect(tm:Get("b").v).is(2) + + tm:Destroy() + end) + + test("Swap keeps both tables tracked (deferred prune + liveness guard)", function() + local data = { a = { id = "a" }, b = { id = "b" } } + local tm = TableManager.new(data) :: any + local pm = tm._proxyManager + + local aTable = data.a + local bTable = data.b + local _touchA = tm.Proxy.a + local _touchB = tm.Proxy.b + + tm:Swap("a", "b") + + expect(tm:Get("a")).is(bTable) + expect(tm:Get("b")).is(aTable) + expect(pm._originalToProxy[aTable]).exists() + expect(pm._originalToProxy[bTable]).exists() + + tm:Destroy() + end) + + test("held orphan proxies keep working after their subtree is removed", function() + local data = { a = { v = 1 } } + local tm = TableManager.new(data) :: any + + local aTable = data.a + local orphan = tm.Proxy.a + tm:Set("a", nil) + + -- The write lands on the detached table without erroring. + orphan.v = 99 + expect(aTable.v).is(99) + expect(tm:Get("a", true)).never_exists() + + tm:Destroy() + end) + end) + + describe("ListenerRegistry cleanup", function() + test("Destroy marks every connection disconnected", function() + local registry = ListenerRegistry.new() + local noop = function() end + local connections = {} + for _i = 1, 4 do + table.insert(connections, registry:RegisterListener("ValueChanged", { "a" }, noop)) + end + table.insert(connections, registry:RegisterListener("ValueChanged", { "a", "b" }, noop)) + table.insert(connections, registry:RegisterListener("ValueChanged", { "players", "*", "hp" }, noop)) + + registry:Destroy() + + for index, connection in connections do + expect(connection.Connected).with_context(`connection #{index}`).never_is_true() + end + end) + + test("Disconnect and Once-fires prune empty tree nodes", function() + local registry = ListenerRegistry.new() :: any + local connA = registry:RegisterListener("ValueChanged", { "a", "b", "c" }, function() end) + local connB = registry:RegisterListener("ValueChanged", { "x", "*", "y" }, function() end) + + connA:Disconnect() + connB:Disconnect() + expect(next(registry._root.Children)).never_exists() + + local fired = 0 + registry:RegisterListener("KeyAdded", { "deep", "path" }, function() + fired += 1 + end, { Once = true }) + registry:FireListenersExact("KeyAdded", { "deep", "path" }, { Key = "k", NewValue = 1 }) + expect(fired).is(1) + expect(next(registry._root.Children)).never_exists() + + registry:Destroy() + end) + + test("registering on a destroyed registry is inert", function() + local registry = ListenerRegistry.new() :: any + registry:Destroy() + + local connection = registry:RegisterListener("ValueChanged", { "a" }, function() end) + expect(connection.Connected).never_is_true() + expect(function() + connection:Disconnect() + end).never_fails() + expect(next(registry._root.Children)).never_exists() + expect(registry._root.Count).is(0) + end) + end) + + describe("TableManager lifecycle", function() + test("Destroy mid-batch releases batch state", function() + local tm = TableManager.new({ a = 1 }) :: any + tm:Suspend() + tm:Set("a", 2) + expect(tm._batch).exists() + + tm:Destroy() + expect(tm._batch).never_exists() + expect(tm._batchDepth).is(0) + end) + + test("re-entrant writes drain the ChangeDetector dispatch queue", function() + local tm = TableManager.new({ v = 1, w = 1 }) :: any + tm:OnValueChange("v", function(newValue) + if newValue < 10 then + tm:Set("w", newValue * 2) + end + end) + + tm:Set("v", 5) + expect(tm:Get("w")).is(10) + expect(#tm._changeDetector._dispatchQueue).is(0) + + tm:Destroy() + end) + + test("registering on a destroyed TableManager returns a dead connection", function() + local tm = TableManager.new({ a = 1 }) :: any + tm:Destroy() + + local connection = tm:OnChange("a", function() end) + expect(connection.Connected).never_is_true() + expect(next(tm._listenerRegistry._root.Children)).never_exists() + expect(tm._listenerRegistry._root.Count).is(0) + end) + end) + + describe("GC collectability (best-effort, pressure-driven)", function() + -- Fires one small unrelated event so the shared listener runner thread's + -- stack slots cannot pin payloads from a previous fire. + local function recycleRunnerThread() + local recycler = TableManager.new({ x = 0 }) + recycler:OnValueChange("x", function() end) + recycler:Set("x", 1) + recycler:Destroy() + end + + test("a removed subtree becomes garbage collectable", function() + local tm = TableManager.new({ a = { b = { payload = 1 } } }) :: any + + local isAlive = (function() + local _touch = tm.Proxy.a.b -- force proxy creation + local probe = GcHelpers.WeakProbe(tm:Get("a")) + tm:Set("a", nil) + return probe + end)() + + expect(GcHelpers.WaitForCollection(isAlive)).is_true() + tm:Destroy() + end) + + test("a destroyed TableManager becomes garbage collectable", function() + local isAlive = (function() + local tm = TableManager.new({ a = { b = 1 } }) :: any + tm:OnChange("a", function() end) + local _touch = tm.Proxy.a + tm:Set("a.b", 2) + local probe = GcHelpers.WeakProbe(tm) + tm:Destroy() + return probe + end)() + + recycleRunnerThread() + expect(GcHelpers.WaitForCollection(isAlive)).is_true() + end) + + test("the listener runner thread does not pin event payloads", function() + local isAlive = (function() + local tm = TableManager.new({ v = 0 :: any }) :: any + tm:OnValueChange("v", function(_newValue) end) + + local payload = table.create(64, "payload") + tm:Set("v", payload) + local probe = GcHelpers.WeakProbe(payload) + tm:Set("v", 0) + tm:Destroy() + return probe + end)() + + recycleRunnerThread() + expect(GcHelpers.WaitForCollection(isAlive)).is_true() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/OpaqueRegistry.spec.luau b/lib/tablemanager2/src/Tests/OpaqueRegistry.spec.luau new file mode 100644 index 00000000..98de6756 --- /dev/null +++ b/lib/tablemanager2/src/Tests/OpaqueRegistry.spec.luau @@ -0,0 +1,175 @@ +--!strict +--[[ + Unit tests for OpaqueRegistry: wrapper purity, registration (per-manager + and global, Opaque and OpaqueChildren), the isOpaque/hasOpaqueChildren + oracle, and weak-keyed GC behavior. +]] + +return function(t: tiniest) + local OpaqueRegistry = require("../OpaqueRegistry") + local GcHelpers = require("./Helpers/GcHelpers") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("wrapper constructors", function() + test("Opaque/GlobalOpaque/OpaqueChildren/GlobalOpaqueChildren have no registration side effect at call time", function() + local registries = OpaqueRegistry.NewRegistries() + local obj = { x = 1 } + + OpaqueRegistry.Opaque(obj) + OpaqueRegistry.GlobalOpaque(obj) + OpaqueRegistry.OpaqueChildren(obj) + OpaqueRegistry.GlobalOpaqueChildren(obj) + + expect(registries.Count).is(0) + expect(registries.Opaque[obj]).is(nil) + expect(registries.OpaqueChildren[obj]).is(nil) + end) + + test("wrapper returns a table distinct from the wrapped value", function() + local obj = { x = 1 } + local wrapper = OpaqueRegistry.Opaque(obj) + + expect(wrapper).never_is(obj) + expect(type(wrapper)).is("table") + end) + end) + + describe("UnwrapAndRegister", function() + test("returns the inner value unchanged (same reference)", function() + local registries = OpaqueRegistry.NewRegistries() + local obj = { x = 1 } + + local result = OpaqueRegistry.UnwrapAndRegister(registries, OpaqueRegistry.Opaque(obj)) + + expect(result).is(obj) + end) + + test("registers Opaque into the per-manager registry, not the global one", function() + local registries = OpaqueRegistry.NewRegistries() + local obj = { x = 1 } + + OpaqueRegistry.UnwrapAndRegister(registries, OpaqueRegistry.Opaque(obj)) + + expect(registries.Opaque[obj]).is(true) + expect(registries.OpaqueChildren[obj]).is(nil) + expect(registries.Count).is(1) + + local otherRegistries = OpaqueRegistry.NewRegistries() + local ctx = OpaqueRegistry.MakeCtx(otherRegistries) + expect(ctx.isOpaque(obj)).is(false) -- not visible to an unrelated manager + end) + + test("registers OpaqueChildren into the per-manager registry", function() + local registries = OpaqueRegistry.NewRegistries() + local container = { items = {} } + + OpaqueRegistry.UnwrapAndRegister(registries, OpaqueRegistry.OpaqueChildren(container)) + + expect(registries.OpaqueChildren[container]).is(true) + expect(registries.Opaque[container]).is(nil) + expect(registries.Count).is(1) + end) + + test("registers GlobalOpaque so any manager's ctx resolves it as opaque", function() + local obj = { tag = "global-opaque-test" } + OpaqueRegistry.UnwrapAndRegister(OpaqueRegistry.NewRegistries(), OpaqueRegistry.GlobalOpaque(obj)) + + local unrelatedRegistries = OpaqueRegistry.NewRegistries() + local ctx = OpaqueRegistry.MakeCtx(unrelatedRegistries) + + expect(ctx.isOpaque(obj)).is(true) + expect(unrelatedRegistries.Opaque[obj]).is(nil) -- lives in the global registry, not this manager's + end) + + test("registers GlobalOpaqueChildren so any manager's ctx resolves it", function() + local container = { tag = "global-opaque-children-test" } + OpaqueRegistry.UnwrapAndRegister(OpaqueRegistry.NewRegistries(), OpaqueRegistry.GlobalOpaqueChildren(container)) + + local unrelatedRegistries = OpaqueRegistry.NewRegistries() + local ctx = OpaqueRegistry.MakeCtx(unrelatedRegistries) + + expect(ctx.hasOpaqueChildren(container)).is(true) + end) + + test("a non-wrapper value passes through untouched and unregistered", function() + local registries = OpaqueRegistry.NewRegistries() + local obj = { x = 1 } + + local result = OpaqueRegistry.UnwrapAndRegister(registries, obj) + + expect(result).is(obj) + expect(registries.Count).is(0) + end) + + test("a scalar wrapper unwraps to the scalar without registering", function() + local registries = OpaqueRegistry.NewRegistries() + + local result = OpaqueRegistry.UnwrapAndRegister(registries, OpaqueRegistry.Opaque(5)) + + expect(result).is(5) + expect(registries.Count).is(0) + end) + + test("re-registering the same value does not double-count", function() + local registries = OpaqueRegistry.NewRegistries() + local obj = { x = 1 } + + OpaqueRegistry.UnwrapAndRegister(registries, OpaqueRegistry.Opaque(obj)) + OpaqueRegistry.UnwrapAndRegister(registries, OpaqueRegistry.Opaque(obj)) + + expect(registries.Count).is(1) + end) + end) + + describe("oracle (MakeCtx)", function() + test("isOpaque/hasOpaqueChildren resolve per-manager marks", function() + local registries = OpaqueRegistry.NewRegistries() + local opaqueObj = { a = 1 } + local childrenContainer = { items = {} } + + OpaqueRegistry.UnwrapAndRegister(registries, OpaqueRegistry.Opaque(opaqueObj)) + OpaqueRegistry.UnwrapAndRegister(registries, OpaqueRegistry.OpaqueChildren(childrenContainer)) + + local ctx = OpaqueRegistry.MakeCtx(registries) + + expect(ctx.isOpaque(opaqueObj)).is(true) + expect(ctx.isOpaque(childrenContainer)).is(false) + expect(ctx.hasOpaqueChildren(childrenContainer)).is(true) + expect(ctx.hasOpaqueChildren(opaqueObj)).is(false) + expect(ctx.isOpaque({})).is(false) + end) + + test("MakeCtx closures stay correct as marks are added after construction", function() + local registries = OpaqueRegistry.NewRegistries() + local ctx = OpaqueRegistry.MakeCtx(registries) + local obj = { a = 1 } + + expect(ctx.isOpaque(obj)).is(false) + + OpaqueRegistry.UnwrapAndRegister(registries, OpaqueRegistry.Opaque(obj)) + + expect(ctx.isOpaque(obj)).is(true) + end) + end) + + describe("garbage collection", function() + test("a per-manager Opaque mark does not keep the object alive (weak-keyed)", function() + local registries = OpaqueRegistry.NewRegistries() + local isAlive: () -> boolean + + do + local obj = { payload = "leak-check" } + isAlive = GcHelpers.WeakProbe(obj) + OpaqueRegistry.UnwrapAndRegister(registries, OpaqueRegistry.Opaque(obj)) + end + + local collected = GcHelpers.WaitForCollection(isAlive) + + expect(collected).is_true() + expect(GcHelpers.CountKeys(registries.Opaque)).is(0) + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/ProxyManager.spec.luau b/lib/tablemanager2/src/Tests/ProxyManager.spec.luau new file mode 100644 index 00000000..72a1ce2c --- /dev/null +++ b/lib/tablemanager2/src/Tests/ProxyManager.spec.luau @@ -0,0 +1,367 @@ +--!strict +--[[ + Unit tests for ProxyManager_new to verify: + - Proxy creation for nested structures + - Metadata tracking (ArrayLength, Original) + - Write handling (SetWriteHandler delegation; default passthrough writes) + - GetOriginal/GetMetadata helpers + - Array metadata tracking + - Edge cases (nil values, deep nesting, scalars, cleanup) + + Note: These are UNIT tests for ProxyManager in isolation. + Full TableManager integration (change detection, batching, etc.) + is tested at the TableManager integration level. +]] + +return function(t: tiniest) + local ProxyManager = require("../ProxyManager") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("Proxy Creation", function() + test("should create a proxy for a table", function() + local data = { health = 100 } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + expect(proxy).exists() + expect(proxy.health).is(100) + + manager:Destroy() + end) + + test("should create nested proxies automatically", function() + local data = { + player = { health = 100, stats = { level = 5 } }, + } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + -- Access nested table - should return a proxy + local playerProxy = proxy.player + expect(playerProxy).exists() + expect(playerProxy.health).is(100) + expect(playerProxy.stats.level).is(5) + + manager:Destroy() + end) + + test("should handle scalar values in tables", function() + local data = { + name = "Alice", + age = 25, + active = true, + } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + expect(proxy.name).is("Alice") + expect(proxy.age).is(25) + expect(proxy.active).is_true() + + manager:Destroy() + end) + + test("should handle arrays", function() + local data = { + items = { "Sword", "Shield", "Potion" }, + } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + expect(proxy.items[1]).is("Sword") + expect(proxy.items[2]).is("Shield") + expect(proxy.items[3]).is("Potion") + + manager:Destroy() + end) + end) + + describe("Metadata Tracking", function() + test("should track metadata for proxies", function() + local data = { health = 100 } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + local meta = manager:GetMetadata(proxy) + + expect(meta).exists() + expect(meta.Original).is(data) + expect(manager:GetPath(proxy)).is_shallow_equal {} + + manager:Destroy() + end) + + test("should track ArrayLength for arrays", function() + local data = { items = { "Sword", "Shield", "Potion" } } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + local itemsProxy = proxy.items + local meta = manager:GetMetadata(itemsProxy) + + expect(meta).exists() + expect(meta.ArrayLength).is(3) + + manager:Destroy() + end) + + test("should record ArrayLength at proxy-creation time", function() + local data = { items = { "Sword" } } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + local itemsProxy = proxy.items + local meta = manager:GetMetadata(itemsProxy) + + expect(meta.ArrayLength).is(1) + + -- Add item (this will update the original table) + data.items[2] = "Shield" + -- Note: ArrayLength is only recorded at proxy-creation time; ProxyManager + -- no longer maintains it on subsequent writes. + + manager:Destroy() + end) + end) + + describe("GetOriginal Helper", function() + test("should return original table for proxy", function() + local data = { health = 100 } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + local original = manager:GetOriginal(proxy) + + expect(original).is(data) + + manager:Destroy() + end) + + test("should return scalar values unchanged", function() + local manager = ProxyManager.new {} + + expect(manager:GetOriginal(42)).is(42) + expect(manager:GetOriginal("hello")).is("hello") + expect(manager:GetOriginal(true)).is_true() + expect(manager:GetOriginal(nil)).is(nil) + + manager:Destroy() + end) + + test("should return non-proxy tables unchanged", function() + local manager = ProxyManager.new {} + + local regularTable = { x = 1, y = 2 } + local result = manager:GetOriginal(regularTable) + + expect(result).is(regularTable) + + manager:Destroy() + end) + end) + + describe("Write Handling", function() + test("should modify original data when proxy changes (no write handler)", function() + local data = { health = 100 } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + -- Change value through proxy + proxy.health = 50 + + -- Verify the original data was modified + expect(data.health).is(50) + + manager:Destroy() + end) + + test("should modify nested original data when proxy changes (no write handler)", function() + local data = { + player = { health = 100, mana = 50 }, + } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + -- Change nested value through proxy + proxy.player.health = 75 + + -- Verify the original nested data was modified + expect(data.player.health).is(75) + + manager:Destroy() + end) + + test("should delegate writes to SetWriteHandler with the resolved path and unwrapped value", function() + local data = { + player = { health = 100 }, + } + local manager = ProxyManager.new(data) + + local calls: { { path: { any }, value: any } } = {} + manager:SetWriteHandler(function(path, value) + table.insert(calls, { path = path, value = value }) + end) + + local proxy = manager:CreateProxy(data) + proxy.player.health = 75 + + expect(#calls).is(1) + expect(calls[1].path).is_shallow_equal { "player", "health" } + expect(calls[1].value).is(75) + + -- The write handler is responsible for applying the mutation; since it + -- only records the call here, the original data is left untouched. + expect(data.player.health).is(100) + + manager:Destroy() + end) + end) + + describe("Array Operations", function() + test("should handle direct array modifications", function() + local data = { items = { "Sword" } } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + -- Directly modify the underlying array + -- Note: ProxyManager doesn't intercept table.insert itself + -- That's handled by TableManager using SetArrayInsertedCallback + data.items[2] = "Shield" + + -- Verify the change is reflected in both proxy and original + expect(proxy.items[2]).is("Shield") + expect(data.items[2]).is("Shield") + + manager:Destroy() + end) + + test("should track ArrayLength metadata", function() + local data = { items = { "Sword", "Shield" } } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + local itemsProxy = proxy.items + local meta = manager:GetMetadata(itemsProxy) + + expect(meta).exists() + expect(meta.ArrayLength).is(2) + + manager:Destroy() + end) + + test("table.insert on proxy arrays is rejected natively", function() + local data = { items = { "Sword", "Shield" } } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + expect(function() + table.insert(proxy.items, "Potion") + end).fails_with("invalid argument #1 to 'insert' %(table expected, got userdata%)") + + expect(data.items[1]).is("Sword") + expect(data.items[2]).is("Shield") + expect(data.items[3]).is(nil) + + manager:Destroy() + end) + + test("table.remove on proxy arrays is rejected natively", function() + local data = { items = { "Sword", "Shield" } } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + expect(function() + table.remove(proxy.items, 1) + end).fails_with("invalid argument #1 to 'remove' %(table expected, got userdata%)") + + expect(data.items[1]).is("Sword") + expect(data.items[2]).is("Shield") + expect(data.items[3]).is(nil) + + manager:Destroy() + end) + end) + + describe("Edge Cases", function() + test("should handle nil values", function() + local data = { health = 100, mana = nil } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + expect(proxy.health).is(100) + expect(proxy.mana).is(nil) + + manager:Destroy() + end) + + test("should handle setting values to nil", function() + local data: { [any]: any } = { health = 100, mana = 50 } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + proxy.mana = nil + + expect(data.mana).is(nil) + + manager:Destroy() + end) + + test("should handle empty tables", function() + local data = {} + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + expect(proxy).exists() + + manager:Destroy() + end) + + test("should handle deeply nested structures", function() + local data = { + level1 = { + level2 = { + level3 = { + level4 = { value = 42 }, + }, + }, + }, + } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + expect(proxy.level1.level2.level3.level4.value).is(42) + + manager:Destroy() + end) + + test("should not create proxy for scalar values", function() + local data = { name = "Alice" } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + -- Accessing scalar should return the scalar, not a proxy + local name = proxy.name + expect(name).is("Alice") + expect(type(name)).is("string") + + manager:Destroy() + end) + end) + + describe("Cleanup", function() + test("should clean up on Destroy", function() + local data = { health = 100 } + local manager = ProxyManager.new(data) + local proxy = manager:CreateProxy(data) + + manager:Destroy() + + -- After destroy, metadata should not be accessible + local meta = manager:GetMetadata(proxy) + expect(meta).is(nil) + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.array-advanced-methods.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.array-advanced-methods.spec.luau new file mode 100644 index 00000000..d8bf8b40 --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.array-advanced-methods.spec.luau @@ -0,0 +1,299 @@ +--!strict +return function(t: tiniest) + local TableManager = require("../../TableManager") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("Method: ArrayInsert", function() + test("supports both path and proxy targets", function() + local manager = TableManager.new { + items = { "Sword" }, + } + + manager:ArrayInsert({ "items" }, "Shield") + manager:ArrayInsert(manager.Proxy.items, "Potion") + + expect(manager.Proxy.items[1]).is("Sword") + expect(manager.Proxy.items[2]).is("Shield") + expect(manager.Proxy.items[3]).is("Potion") + + manager:Destroy() + end) + + test("updates held proxy key after insert shift", function() + local manager = TableManager.new { + items = { { value = 10 } }, + } + + local heldProxy = manager.Proxy.items[1] + expect(heldProxy).exists() + + local capturedPath = nil + manager:OnValueChange({ "items", 2, "value" }, function(_new, _old, metadata) + capturedPath = metadata.OriginPath + end) + + manager:ArrayInsert({ "items" }, 1, { value = 99 }) + heldProxy.value = 42 + + expect(manager:Get { "items", 2, "value" }).is(42) + expect(capturedPath).exists() + assert(capturedPath, "Expected capturedPath to be set in OnValueChange callback") + expect(capturedPath[1]).is("items") + expect(capturedPath[2]).is(2) + expect(capturedPath[3]).is("value") + + manager:Destroy() + end) + + test("notifies ancestors for array insert", function() + local manager = TableManager.new { + game = { + players = { "Alice", "Bob" }, + }, + } + + local gameNotified = 0 + local gameKeyChanged = 0 + local originPath = nil + + manager:OnChange({ "game" }, function(_newValue, _oldValue, metadata) + gameNotified += 1 + originPath = metadata.OriginPath + end) + + manager:OnKeyChange({ "game" }, function(key, _newValue, _oldValue) + gameKeyChanged += 1 + expect(key).is("players") + end) + + manager:ArrayInsert({ "game", "players" }, "Charlie") + + expect(gameNotified).is(1) + expect(gameKeyChanged).is(1) + expect(originPath).exists() + assert(originPath, "Expected originPath to be set in OnValueChange callback") + expect(#originPath).is(3) + + manager:Destroy() + end) + + test("does not shift proxies in sibling arrays", function() + local manager = TableManager.new { + a = { { value = 1 } }, + b = { { value = 2 } }, + } + + local bProxy1 = manager.Proxy.b[1] + manager:ArrayInsert({ "a" }, 1, { value = 99 }) + + bProxy1.value = 77 + expect(manager:Get { "b", 1, "value" }).is(77) + + manager:Destroy() + end) + + test("fresh proxy access returns same object after index shift", function() + local manager = TableManager.new { + items = { { value = 10 } }, + } + + local heldProxy = manager.Proxy.items[1] + manager:ArrayInsert({ "items" }, 1, { value = 99 }) + + local freshProxy = manager.Proxy.items[2] + expect(freshProxy).is(heldProxy) + + manager:Destroy() + end) + + test("nested array insert updates deeply held proxy live path", function() + local manager = TableManager.new { + items = { { nested = { { v = 1 }, { v = 2 } } } }, + } + + local heldProxy = manager.Proxy.items[1].nested[2] + expect(heldProxy).exists() + + manager:ArrayInsert(manager.Proxy.items[1].nested, 1, { v = 99 }) + heldProxy.v = 42 + + expect(manager.Proxy.items[1].nested[1].v).is(99) + expect(manager.Proxy.items[1].nested[3].v).is(42) + + manager:Destroy() + end) + + test("tostring for held proxy reflects updated index after insert", function() + local manager = TableManager.new { + items = { { value = 1 } }, + } + + local heldProxy = manager.Proxy.items[1] + expect(tostring(heldProxy)).is("TableManager.Data(items.1)") + + manager:ArrayInsert({ "items" }, 1, { value = 99 }) + expect(tostring(heldProxy)).is("TableManager.Data(items.2)") + + manager:Destroy() + end) + + test("iteration after insert shift yields current keys", function() + local manager = TableManager.new { + items = { { v = 1 }, { v = 2 } }, + } + + local _ = manager.Proxy.items[1] + manager:ArrayInsert({ "items" }, 1, { v = 0 }) + + local keys = {} + for k in manager.Proxy.items do + table.insert(keys, k) + end + table.sort(keys) + + expect(#keys).is(3) + expect(keys[1]).is(1) + expect(keys[2]).is(2) + expect(keys[3]).is(3) + + manager:Destroy() + end) + end) + + describe("Method: ArrayRemove", function() + test("supports both path and proxy targets", function() + local manager = TableManager.new { + items = { "Sword", "Shield", "Potion" }, + } + + local removedByPath = manager:ArrayRemove({ "items" }, 1) + local removedByProxy = manager:ArrayRemove(manager.Proxy.items, 1) + + expect(removedByPath).is("Sword") + expect(removedByProxy).is("Shield") + expect(manager.Proxy.items[1]).is("Potion") + expect(manager.Proxy.items[2]).is(nil) + + manager:Destroy() + end) + + test("updates held proxy key after remove shift", function() + local manager = TableManager.new { + items = { { value = 0 }, { value = 20 } }, + } + + local heldProxy = manager.Proxy.items[2] + expect(heldProxy).exists() + + local capturedPath = nil + manager:OnValueChange({ "items", 1, "value" }, function(_new, _old, metadata) + capturedPath = metadata.OriginPath + end) + + manager:ArrayRemove({ "items" }, 1) + heldProxy.value = 77 + + expect(manager:Get { "items", 1, "value" }).is(77) + assert(capturedPath, "Expected capturedPath to be set in OnValueChange callback") + expect(capturedPath[1]).is("items") + expect(capturedPath[2]).is(1) + expect(capturedPath[3]).is("value") + + manager:Destroy() + end) + + test("notifies ancestors for array remove", function() + local manager = TableManager.new { + game = { + players = { "Alice", "Bob", "Charlie" }, + }, + } + + local notifiedCount = 0 + manager:OnChange({ "game" }, function() + notifiedCount += 1 + end) + + manager:ArrayRemove({ "game", "players" }, 2) + expect(notifiedCount).is(1) + + manager:Destroy() + end) + + test("dead proxy after remove does not corrupt live array", function() + local manager = TableManager.new { + items = { { value = 10 }, { value = 20 } }, + } + + local deadProxy = manager.Proxy.items[1] + local survivorProxy = manager.Proxy.items[2] + + manager:ArrayRemove({ "items" }, 1) + expect(manager:Get { "items", 1, "value" }).is(20) + + -- A proxy is bound to the table object it represents, not the path it + -- once resolved to. deadProxy's original table (value=10) was removed + -- from the array entirely, so writing through it only mutates that + -- detached table and does not affect the survivor now living at items[1]. + deadProxy.value = 999 + expect(survivorProxy.value).is(20) + expect(manager:Get { "items", 1, "value" }).is(20) + + manager:Destroy() + end) + + test("multiple inserts then remove preserve accumulated held proxy shift", function() + local manager = TableManager.new { + items = { { value = 10 } }, + } + + local heldProxy = manager.Proxy.items[1] + manager:ArrayInsert({ "items" }, 1, { value = 1 }) + manager:ArrayInsert({ "items" }, 1, { value = 2 }) + manager:ArrayInsert({ "items" }, 1, { value = 3 }) + + heldProxy.value = 99 + + expect(manager:Get { "items", 4, "value" }).is(99) + expect(manager:Get { "items", 1, "value" }).is(3) + expect(manager:Get { "items", 2, "value" }).is(2) + expect(manager:Get { "items", 3, "value" }).is(1) + + manager:Destroy() + end) + + test("table.remove on proxy array is rejected", function() + local manager = TableManager.new { + items = { "Sword", "Shield" }, + } + + expect(function() + table.remove(manager.Proxy.items, 1) + end).fails_with("invalid argument #1 to 'remove' %(table expected, got userdata%)") + + expect(manager.Proxy.items[1]).is("Sword") + expect(manager.Proxy.items[2]).is("Shield") + expect(manager.Proxy.items[3]).is(nil) + + manager:Destroy() + end) + + test("removing from one array does not shift held proxy in sibling array", function() + local manager = TableManager.new { + a = { { value = 1 }, { value = 2 } }, + b = { { value = 10 }, { value = 20 } }, + } + + local heldB = manager.Proxy.b[2] + manager:ArrayRemove({ "a" }, 1) + + heldB.value = 77 + expect(manager:Get { "b", 2, "value" }).is(77) + + manager:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.array-helper-methods.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.array-helper-methods.spec.luau new file mode 100644 index 00000000..d8373b6c --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.array-helper-methods.spec.luau @@ -0,0 +1,204 @@ +--!strict + +return function(t: tiniest) + local TableManager = require("../../TableManager") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("Method: ArrayRemoveFirstValue", function() + test("removes first matching value and returns removed index", function() + local manager = TableManager.new { + items = { "A", "B", "A" }, + } + + local index = manager:ArrayRemoveFirstValue({ "items" }, "A") + expect(index).is(1) + expect(manager.Proxy.items[1]).is("B") + expect(manager.Proxy.items[2]).is("A") + + manager:Destroy() + end) + + test("supports proxy target and fires ArrayRemoved signal", function() + local manager = TableManager.new { + items = { "A", "B", "C" }, + } + + local listenerCount = 0 + local signalCount = 0 + local signalPath = nil + local signalIndex = nil + local signalValue = nil + + manager:OnArrayRemove({ "items" }, function(index, oldValue) + listenerCount += 1 + expect(index).is(2) + expect(oldValue).is("B") + end) + + local conn = manager.ArrayRemoved:Connect(function(path, index, oldValue) + signalCount += 1 + signalPath = path + signalIndex = index + signalValue = oldValue + end) + + local index = manager:ArrayRemoveFirstValue(manager.Proxy.items, "B") + expect(index).is(2) + + expect(listenerCount).is(1) + expect(signalCount).is(1) + expect(signalPath).is_shallow_equal { "items" } + expect(signalIndex).is(2) + expect(signalValue).is("B") + + conn:Disconnect() + manager:Destroy() + end) + + test("returns nil when value is not found", function() + local manager = TableManager.new { + items = { "A", "B" }, + } + + local index = manager:ArrayRemoveFirstValue({ "items" }, "Z") + expect(index).is(nil) + + manager:Destroy() + end) + + test("errors when target is not a table", function() + local manager = TableManager.new { + notArray = 5, + } + + expect(function() + manager:ArrayRemoveFirstValue({ "notArray" }, 5) + end).fails_with("Target is not a table") + + manager:Destroy() + end) + end) + + describe("Method: ArraySwapRemove", function() + test("returns removed value and performs swap-remove", function() + local manager = TableManager.new { + items = { "A", "B", "C" }, + } + + local removed = manager:ArraySwapRemove({ "items" }, 1) + expect(removed).is("A") + expect(manager.Proxy.items[1]).is("C") + expect(manager.Proxy.items[2]).is("B") + expect(manager.Proxy.items[3]).is(nil) + + manager:Destroy() + end) + + test("supports proxy target and fires ArrayRemoved + ArraySet signals", function() + local manager = TableManager.new { + items = { "A", "B", "C" }, + } + + local removedCount = 0 + local setCount = 0 + local removedIndex = nil + local setIndex = nil + + local removedConn = manager.ArrayRemoved:Connect(function(_path, index, oldValue) + removedCount += 1 + removedIndex = index + expect(oldValue).is("C") + end) + local setConn = manager.ArraySet:Connect(function(_path, index, newValue, oldValue) + setCount += 1 + setIndex = index + expect(newValue).is("C") + expect(oldValue).is("B") + end) + + local removed = manager:ArraySwapRemove(manager.Proxy.items, 2) + expect(removed).is("B") + + expect(removedCount).is(1) + expect(setCount).is(1) + expect(removedIndex).is(3) + expect(setIndex).is(2) + + removedConn:Disconnect() + setConn:Disconnect() + manager:Destroy() + end) + + test("returns nil for out-of-range index", function() + local manager = TableManager.new { + items = { "A", "B" }, + } + + local removed = manager:ArraySwapRemove({ "items" }, 99) + expect(removed).is(nil) + expect(manager.Proxy.items[1]).is("A") + expect(manager.Proxy.items[2]).is("B") + + manager:Destroy() + end) + end) + + describe("Method: ArraySwapRemoveFirstValue", function() + test("removes first matching value and returns removed index", function() + local manager = TableManager.new { + items = { "A", "B", "C" }, + } + + local index = manager:ArraySwapRemoveFirstValue({ "items" }, "B") + expect(index).is(2) + expect(manager.Proxy.items[1]).is("A") + expect(manager.Proxy.items[2]).is("C") + expect(manager.Proxy.items[3]).is(nil) + + manager:Destroy() + end) + + test("supports proxy target and triggers remove + set listener callbacks", function() + local manager = TableManager.new { + items = { "A", "B", "C" }, + } + + local removeListenerCount = 0 + local setListenerCount = 0 + + manager:OnArrayRemove({ "items" }, function(index, oldValue) + removeListenerCount += 1 + expect(index).is(3) + expect(oldValue).is("C") + end) + + manager:OnArraySet({ "items" }, function(index, newValue, oldValue) + setListenerCount += 1 + expect(index).is(2) + expect(newValue).is("C") + expect(oldValue).is("B") + end) + + local index = manager:ArraySwapRemoveFirstValue(manager.Proxy.items, "B") + expect(index).is(2) + expect(removeListenerCount).is(1) + expect(setListenerCount).is(1) + + manager:Destroy() + end) + + test("returns nil when value is not found", function() + local manager = TableManager.new { + items = { "A", "B" }, + } + + local index = manager:ArraySwapRemoveFirstValue({ "items" }, "Z") + expect(index).is(nil) + + manager:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.batch-lifecycle-methods.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.batch-lifecycle-methods.spec.luau new file mode 100644 index 00000000..1cc1735e --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.batch-lifecycle-methods.spec.luau @@ -0,0 +1,229 @@ +--!strict + +return function(t: tiniest) + local TableManager = require("../../TableManager") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("Method: Batch", function() + test("suppresses intermediate signals and fires at flush", function() + local manager = TableManager.new { + player = { health = 100, mana = 50 }, + } + + local valueChangedCount = 0 + local keyChangedCount = 0 + manager.ValueChanged:Connect(function() + valueChangedCount += 1 + end) + manager.KeyChanged:Connect(function() + keyChangedCount += 1 + end) + + manager:Batch(function() + manager.Proxy.player.health = 80 + manager.Proxy.player.health = 60 + manager.Proxy.player.mana = 30 + end) + + expect(valueChangedCount).is(2) + expect(keyChangedCount).is(2) + + manager:Destroy() + end) + + test("with no net change fires nothing", function() + local manager = TableManager.new { + x = 1, + } + + local fired = 0 + manager.ValueChanged:Connect(function() + fired += 1 + end) + + manager:Batch(function() + manager.Proxy.x = 99 + manager.Proxy.x = 1 + end) + + expect(fired).is(0) + + manager:Destroy() + end) + + test("nested Batch call is a no-op for inner window", function() + local manager = TableManager.new { + a = 1, + } + + local fired = 0 + manager.ValueChanged:Connect(function() + fired += 1 + end) + + manager:Batch(function() + manager:Batch(function() + manager.Proxy.a = 2 + end) + end) + + expect(fired).is(1) + expect(manager.Proxy.a).is(2) + + manager:Destroy() + end) + + test("array insert events are deferred until flush", function() + local manager = TableManager.new { + items = { "a", "b" }, + } + + local firedDuringBatchFn = false + local batchFnRunning = false + manager.ArrayInserted:Connect(function() + if batchFnRunning then + firedDuringBatchFn = true + end + end) + + local insertedEvents: { { index: number, value: any } } = {} + manager.ArrayInserted:Connect(function(_path, index, value) + table.insert(insertedEvents, { index = index, value = value }) + end) + + manager:Batch(function() + batchFnRunning = true + manager:ArrayInsert({ "items" }, "c") + manager:ArrayInsert({ "items" }, "d") + batchFnRunning = false + end) + + expect(firedDuringBatchFn).is(false) + expect(#insertedEvents).is(2) + expect(manager.Proxy.items[3]).is("c") + expect(manager.Proxy.items[4]).is("d") + + manager:Destroy() + end) + end) + + describe("Method: Suspend", function() + test("suppresses immediate events until resume", function() + local manager = TableManager.new { + v = 1, + } + + local fired = 0 + manager.ValueChanged:Connect(function() + fired += 1 + end) + + manager:Suspend() + manager.Proxy.v = 2 + manager.Proxy.v = 3 + expect(fired).is(0) + + manager:Resume() + expect(fired).is(1) + expect(manager.Proxy.v).is(3) + + manager:Destroy() + end) + + test("nested Suspend is a no-op", function() + local manager = TableManager.new { + x = 1, + } + + local fired = 0 + manager.ValueChanged:Connect(function(path) + if path[1] == "x" then + fired += 1 + end + end) + + manager:Suspend() + manager:Suspend() + manager.Proxy.x = 2 + expect(fired).is(0) + + manager:Resume() + expect(manager.Proxy.x).is(2) + expect(fired).is(1) + + manager:Destroy() + end) + + test("defers array inserted signals while suspended", function() + local manager = TableManager.new { + items = { "a" }, + } + + local inserted = 0 + manager.ArrayInserted:Connect(function() + inserted += 1 + end) + + manager:Suspend() + manager:ArrayInsert({ "items" }, "b") + expect(inserted).is(0) + + manager:Resume() + expect(inserted).is(1) + + manager:Destroy() + end) + end) + + describe("Method: Resume", function() + test("without Suspend is a no-op", function() + local manager = TableManager.new { x = 1 } + + manager:Resume() + manager:Resume() + expect(manager.Proxy.x).is(1) + manager:Destroy() + end) + + test("second Resume after flush remains a no-op", function() + local manager = TableManager.new { x = 1 } + + local fired = 0 + manager.ValueChanged:Connect(function() + fired += 1 + end) + + manager:Suspend() + manager.Proxy.x = 2 + manager:Resume() + manager:Resume() + + expect(fired).is(1) + expect(manager.Proxy.x).is(2) + manager:Destroy() + end) + + test("flushes deferred changes after suspend", function() + local manager = TableManager.new { score = 0 } + + local fired = 0 + manager.ValueChanged:Connect(function(path) + if path[1] == "score" then + fired += 1 + end + end) + + manager:Suspend() + manager.Proxy.score = 10 + expect(fired).is(0) + manager:Resume() + + expect(fired).is(1) + expect(manager.Proxy.score).is(10) + manager:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.constructor-schema-method.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.constructor-schema-method.spec.luau new file mode 100644 index 00000000..4b50161c --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.constructor-schema-method.spec.luau @@ -0,0 +1,166 @@ +--!strict + +return function(t: tiniest) + local TableManager = require("../../TableManager") + local T = require("../../../T") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("Method: new", function() + test("creates a manager with initial data", function() + local manager = TableManager.new { + player = { health = 100, level = 5 }, + settings = { volume = 80 }, + } + + expect(manager).exists() + expect(manager.Proxy).exists() + expect(manager.Proxy.player).exists() + expect(manager.Proxy.player.health).is(100) + + manager:Destroy() + end) + + test("re-exports T module for schema helpers", function() + expect(type(TableManager.T)).is("table") + expect(type(TableManager.T.interface)).is("function") + expect(type(TableManager.T.GetMeta)).is("function") + end) + + test("validates initial data against schema at creation", function() + local ok, err = pcall(function() + TableManager.new({ value = "bad" }, { + Schema = T.interface { value = T.number }, + }) + end) + + expect(ok).is(false) + expect(type(err)).is("string") + end) + + test("calls OnValidationFailed for invalid initial data", function() + local failures = 0 + local capturedPath = "" + local capturedMessage = "" + + local ok = pcall(function() + TableManager.new({ value = "bad" }, { + Schema = T.interface { value = T.number }, + OnValidationFailed = function(path, _value, err) + failures += 1 + capturedPath = table.concat(path, ".") + capturedMessage = err + end, + }) + end) + + expect(ok).is(false) + expect(failures).is(1) + expect(capturedPath).is("") + expect(type(capturedMessage)).is("string") + end) + + test("default duplicate mode errors on duplicate references", function() + local manager = TableManager.new { + a = { value = 1 }, + b = { value = 2 }, + } + + expect(function() + manager.Proxy.b = manager.Proxy.a + end).fails_with("Duplicate table reference detected") + + expect(manager.Proxy.a.value).is(1) + expect(manager.Proxy.b.value).is(2) + + manager:Destroy() + end) + + test("DuplicateReferenceMode allow permits duplicate references", function() + local manager = TableManager.new({ + a = { value = 1 }, + b = { value = 2 }, + }, { + DuplicateReferenceMode = "allow", + }) + + manager.Proxy.b = manager.Proxy.a + + expect(manager.Proxy.b).is(manager.Proxy.a) + expect(manager.Proxy.b.value).is(1) + + manager:Destroy() + end) + + test("DuplicateReferenceMode warn permits duplicate references", function() + local manager = TableManager.new({ + a = { value = 1 }, + b = { value = 2 }, + }, { + DuplicateReferenceMode = "warn", + }) + + manager.Proxy.b = manager.Proxy.a + + expect(manager.Proxy.b).is(manager.Proxy.a) + expect(manager.Proxy.b.value).is(1) + + manager:Destroy() + end) + + test("DuplicateReferenceMode copy is not implemented", function() + local manager = TableManager.new({ + a = { value = 1 }, + b = { value = 2 }, + }, { + DuplicateReferenceMode = "copy", + }) + + expect(function() + manager.Proxy.b = manager.Proxy.a + end).fails_with("DuplicateReferenceMode 'copy' is not implemented yet") + + manager:Destroy() + end) + + test("DuplicateReferenceMode move relocates duplicate references", function() + local manager = TableManager.new({ + a = { value = 1 }, + b = { value = 2 }, + }, { + DuplicateReferenceMode = "move", + }) + + manager.Proxy.b = manager.Proxy.a + + expect(manager.Proxy.a).is(nil) + expect(manager.Proxy.b.value).is(1) + + manager:Destroy() + end) + + test("allows unschemed paths to remain permissive", function() + local failures = 0 + local manager = TableManager.new({ + player = { health = 100, mana = 50 }, + }, { + Schema = T.interface { + player = T.interface { + health = T.number, + }, + }, + OnValidationFailed = function() + failures += 1 + end, + }) + + manager.Proxy.player.mana = 10 + expect(manager.Proxy.player.mana).is(10) + expect(failures).is(0) + + manager:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.extend.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.extend.spec.luau new file mode 100644 index 00000000..c3ec86e9 --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.extend.spec.luau @@ -0,0 +1,142 @@ +--!strict + +return function(t: tiniest) + local TableManager = require("../../TableManager") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("TableManager.Extend", function() + test("Extend via proxy: bidirectional fan-out and shared identity", function() + local data = { Stats = { Health = 100 } } + local playerTM = TableManager.new(data) + + local statsTM = playerTM:Extend(playerTM:GetProxy("Stats")) + + expect(statsTM.Raw).is(data.Stats) + expect(playerTM.Linker:IsLinkedWith(statsTM)).is_true() + + local seen + playerTM:OnValueChange("Stats.Health", function(newValue) + seen = newValue + end) + statsTM:Set("Health", 50) + expect(data.Stats.Health).is(50) + expect(seen).is(50) + + local seenOnStats + statsTM:OnValueChange("Health", function(newValue) + seenOnStats = newValue + end) + playerTM:Set("Stats.Health", 75) + expect(seenOnStats).is(75) + + playerTM:Destroy() + statsTM:Destroy() + end) + + test("Extend via path string", function() + local data = { Stats = { Health = 100 } } + local playerTM = TableManager.new(data) + + local statsTM = playerTM:Extend("Stats") + expect(statsTM.Raw).is(data.Stats) + + local seen + statsTM:OnValueChange("Health", function(newValue) + seen = newValue + end) + playerTM:Set("Stats.Health", 10) + expect(seen).is(10) + + playerTM:Destroy() + statsTM:Destroy() + end) + + test("Extend via raw value already in the tree", function() + local data = { Inventory = { { Name = "Sword" } } } + local playerTM = TableManager.new(data) + + local swordTM = playerTM:Extend(data.Inventory[1]) + expect(swordTM.Raw).is(data.Inventory[1]) + + local seen + playerTM:OnValueChange({ "Inventory", 1, "Name" }, function(newValue) + seen = newValue + end) + swordTM:Set("Name", "Axe") + expect(seen).is("Axe") + + playerTM:Destroy() + swordTM:Destroy() + end) + + test("Extend follows array shifts: the parent anchor is recomputed at fan-out time", function() + local data = { Inventory = { { Name = "Sword" }, { Name = "Shield" } } } + local playerTM = TableManager.new(data) + + local swordTM = playerTM:Extend(data.Inventory[1]) + + -- Insert ahead of the extended element; its index shifts from 1 to 2. + playerTM:ArrayInsert("Inventory", 1, { Name = "Dagger" }) + + local seen + playerTM:OnValueChange({ "Inventory", 2, "Name" }, function(newValue) + seen = newValue + end) + swordTM:Set("Name", "Excalibur") + + expect(data.Inventory[2].Name).is("Excalibur") + expect(seen).is("Excalibur") + + playerTM:Destroy() + swordTM:Destroy() + end) + + test("Detachment: replacing the extended object dissolves the group", function() + local data = { Stats = { Health = 100 } } + local playerTM = TableManager.new(data) + + local statsTM = playerTM:Extend("Stats") + + playerTM:Set("Stats", { Health = 999 }) + + expect(playerTM.Linker:IsLinkedWith(statsTM)).never_is_true() + + -- statsTM keeps operating on the orphaned object, independent of + -- playerTM's new "Stats" table. + local seen + statsTM:OnValueChange("Health", function(newValue) + seen = newValue + end) + statsTM:Set("Health", 1) + expect(seen).is(1) + expect(statsTM.Raw.Health).is(1) + expect(data.Stats.Health).is(999) + + playerTM:Destroy() + statsTM:Destroy() + end) + + test("Extend requires a table target", function() + local playerTM = TableManager.new { Stats = { Health = 100 }, Name = "Bob" } + + expect(function() + playerTM:Extend("Name") + end).fails() + + playerTM:Destroy() + end) + + test("Extend does not require proxies", function() + local playerTM = TableManager.new({ Stats = { Health = 100 } }, { EnableProxies = false }) + + expect(function() + playerTM:Extend("Stats") + end).never_fails() + + playerTM:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.for-map-methods.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.for-map-methods.spec.luau new file mode 100644 index 00000000..227efcae --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.for-map-methods.spec.luau @@ -0,0 +1,249 @@ +--!strict + +return function(t: tiniest) + local TableManager = require("../../TableManager") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("Method: ForKeys", function() + test("fires once per existing key on subscribe", function() + local manager = TableManager.new { a = 1, b = 2 } + local seen: { [string]: number } = {} + + manager:ForKeys({}, function(_itemJanitor, key) + seen[key] = (seen[key] or 0) + 1 + end) + + expect(seen.a).is(1) + expect(seen.b).is(1) + + manager:Destroy() + end) + + test("fires again when a new key is added", function() + local manager = TableManager.new { a = 1 } + local count = 0 + + manager:ForKeys({}, function() + count += 1 + end) + expect(count).is(1) + + manager:Set("b", 2) + expect(count).is(2) + + manager:Destroy() + end) + + test("does not re-run the handler when an existing key's value changes", function() + local manager = TableManager.new { a = 1 } + local count = 0 + local capturedJanitor: any = nil + + manager:ForKeys({}, function(itemJanitor) + count += 1 + capturedJanitor = itemJanitor + end) + expect(count).is(1) + + manager:Set("a", 99) + expect(count).is(1) + expect(capturedJanitor).exists() + + manager:Destroy() + end) + + test("destroys the item's Janitor when its key is removed", function() + local manager = TableManager.new { a = 1 } + local destroyed = false + + manager:ForKeys({}, function(itemJanitor) + itemJanitor:Add(function() + destroyed = true + end) + end) + expect(destroyed).never_is_true() + + manager:Set("a", nil :: any) + expect(destroyed).is_true() + + manager:Destroy() + end) + end) + + describe("Method: ForValues", function() + test("tracks duplicate values as independent items", function() + local manager = TableManager.new { items = { 1, 1, 2 } } + local fireCount = 0 + + manager:ForValues("items", function() + fireCount += 1 + end) + + expect(fireCount).is(3) + + manager:Destroy() + end) + + test("does not re-trigger the surviving occurrence when a value moves index", function() + local manager = TableManager.new { items = { "a", "b" } } + local addCount = 0 + local bDestroyed = false + + manager:ForValues("items", function(itemJanitor, value) + addCount += 1 + if value == "b" then + itemJanitor:Add(function() + bDestroyed = true + end) + end + end) + expect(addCount).is(2) + + manager:ArraySwapRemove("items", 1) -- removes "a", backfills with "b" + + expect(addCount).is(2) -- "b"'s occurrence was never torn down/recreated + expect(bDestroyed).never_is_true() + + manager:Destroy() + end) + end) + + describe("Method: ForPairs", function() + test("fires once per existing pair on subscribe", function() + local manager = TableManager.new { a = 1, b = 2 } + local seen: { [string]: number } = {} + + manager:ForPairs({}, function(_itemJanitor, key) + seen[key] = (seen[key] or 0) + 1 + end) + + expect(seen.a).is(1) + expect(seen.b).is(1) + + manager:Destroy() + end) + + test("tears down and re-runs when the value changes at an existing key", function() + local manager = TableManager.new { a = 1 } + local addCount = 0 + local destroyedCount = 0 + + manager:ForPairs({}, function(itemJanitor) + addCount += 1 + itemJanitor:Add(function() + destroyedCount += 1 + end) + end) + expect(addCount).is(1) + expect(destroyedCount).is(0) + + manager:Set("a", 99) + + expect(addCount).is(2) + expect(destroyedCount).is(1) + + manager:Destroy() + end) + end) + + describe("Method: MapKeys", function() + test("derived manager is keyed by transform's return value", function() + local manager = TableManager.new { a = 1, b = 2 } + + local derived = manager:MapKeys({}, function(_entryJanitor, key) + return string.upper(key) + end) + + expect(derived:Get("A")).is(1) + expect(derived:Get("B")).is(2) + + manager:Destroy() + derived:Destroy() + end) + + test("output value refreshes on source value change without re-running transform", function() + local manager = TableManager.new { a = 1 } + local transformCalls = 0 + + local derived = manager:MapKeys({}, function(_entryJanitor, key) + transformCalls += 1 + return string.upper(key) + end) + expect(transformCalls).is(1) + + manager:Set("a", 99) + + expect(derived:Get("A")).is(99) + expect(transformCalls).is(1) + + manager:Destroy() + derived:Destroy() + end) + + test("destroying the derived manager disconnects from the source", function() + local manager = TableManager.new { a = 1 } + + local derived = manager:MapKeys({}, function(_entryJanitor, key) + return string.upper(key) + end) + + derived:Destroy() + + expect(function() + manager:Set("b", 2) + end).never_fails() + + manager:Destroy() + end) + end) + + describe("Method: MapValues", function() + test("derived manager is keyed by source key with transformed values", function() + local manager = TableManager.new { a = 1, b = 2 } + + local derived = manager:MapValues({}, function(_entryJanitor, value) + return value * 10 + end) + + expect(derived:Get("a")).is(10) + expect(derived:Get("b")).is(20) + + manager:Destroy() + derived:Destroy() + end) + + test("output updates when the source value changes", function() + local manager = TableManager.new { a = 1 } + + local derived = manager:MapValues({}, function(_entryJanitor, value) + return value * 10 + end) + expect(derived:Get("a")).is(10) + + manager:Set("a", 5) + expect(derived:Get("a")).is(50) + + manager:Destroy() + derived:Destroy() + end) + end) + + describe("Method: MapPairs", function() + test("derived manager is keyed by transform's first return value", function() + local manager = TableManager.new { a = 1, b = 2 } + + local derived = manager:MapPairs({}, function(_entryJanitor, key, value) + return key .. "_x", value + end) + + expect(derived:Get("a_x")).is(1) + expect(derived:Get("b_x")).is(2) + + manager:Destroy() + derived:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.force-notify-method.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.force-notify-method.spec.luau new file mode 100644 index 00000000..6db1f26e --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.force-notify-method.spec.luau @@ -0,0 +1,138 @@ +--!strict + +return function(t: tiniest) + local TableManager = require("../../TableManager") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("Method: ForceNotify", function() + test("fires listeners for same-table assignments", function() + local manager = TableManager.new { + players = { John = { Stats = { Health = 100, Level = 5 } } }, + } + + local statsListenerCount = 0 + local johnListenerCount = 0 + + local statsConn = manager:OnValueChange( + { "players", "John", "Stats" }, + function(_newValue, _oldValue, metadata) + statsListenerCount += 1 + if metadata.Diff then + expect(metadata.Diff.old).is(metadata.Diff.new) + end + end + ) + + local johnConn = manager:OnChange({ "players", "John" }, function() + johnListenerCount += 1 + end) + + local sameStats = manager.Proxy.players.John.Stats + manager.Proxy.players.John.Stats = sameStats + + expect(statsListenerCount).is(0) + expect(johnListenerCount).is(0) + + manager:ForceNotify { "players", "John", "Stats" } + + expect(statsListenerCount).is(1) + expect(johnListenerCount).is(1) + + statsConn:Disconnect() + johnConn:Disconnect() + manager:Destroy() + end) + + test("provides changed metadata where old equals new", function() + local manager = TableManager.new { + settings = { volume = 80 }, + } + + local hasDiff = false + local oldEqualsNew = false + + local conn = manager:OnValueChange({ "settings", "volume" }, function(_newValue, _oldValue, metadata) + if metadata.Diff then + hasDiff = true + oldEqualsNew = (metadata.Diff.old == metadata.Diff.new) + end + end) + + manager:ForceNotify { "settings", "volume" } + + expect(hasDiff).is_true() + expect(oldEqualsNew).is_true() + + conn:Disconnect() + manager:Destroy() + end) + + test("fires ancestor listeners for forced notifications", function() + local manager = TableManager.new { + game = { + world = { + players = { John = { health = 100 } }, + }, + }, + } + + local gameCount = 0 + local worldCount = 0 + local playersCount = 0 + local johnCount = 0 + local healthCount = 0 + + manager:OnChange({ "game" }, function() + gameCount += 1 + end) + manager:OnChange({ "game", "world" }, function() + worldCount += 1 + end) + manager:OnChange({ "game", "world", "players" }, function() + playersCount += 1 + end) + manager:OnChange({ "game", "world", "players", "John" }, function() + johnCount += 1 + end) + manager:OnChange({ "game", "world", "players", "John", "health" }, function() + healthCount += 1 + end) + + manager:ForceNotify { "game", "world", "players", "John", "health" } + + expect(gameCount).is(1) + expect(worldCount).is(1) + expect(playersCount).is(1) + expect(johnCount).is(1) + expect(healthCount).is(1) + + manager:Destroy() + end) + + test("respects ListenDepth=0 for forced descendant notifications", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local unlimited = 0 + local directOnly = 0 + + manager:OnChange({ "player" }, function() + unlimited += 1 + end) + manager:OnChange({ "player" }, function() + directOnly += 1 + end, { ListenDepth = 0 }) + + manager:ForceNotify { "player", "health" } + + expect(unlimited).is(1) + expect(directOnly).is(0) + + manager:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.ignore-paths.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.ignore-paths.spec.luau new file mode 100644 index 00000000..59e7cd18 --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.ignore-paths.spec.luau @@ -0,0 +1,128 @@ +--!strict +--[[ + Integration coverage for `TableManagerConfig.IgnoredPaths` and the runtime + `tm:SetPathIgnored(path, ignored)`: a write under an ignored prefix still + happens (Get/Raw reflect it) but skips all diff/snapshot/event work. +]] + +return function(t: tiniest) + local TableManager = require("../../TableManager") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("IgnoredPaths / SetPathIgnored", function() + test("IgnoredPaths config suppresses writes under that prefix; sibling paths fire normally", function() + local manager = TableManager.new( + { debug = { counter = 0 }, score = 0 }, + { IgnoredPaths = { { "debug" } } } + ) + + local debugFired = 0 + local scoreFired = 0 + manager:OnChange({ "debug" }, function() + debugFired += 1 + end) + manager:OnChange({ "score" }, function() + scoreFired += 1 + end) + + manager:Set({ "debug", "counter" }, 1) + manager:Set("score", 10) + + expect(manager:Get { "debug", "counter" }).is(1) -- still written + expect(debugFired).is(0) + expect(scoreFired).is(1) + + manager:Destroy() + end) + + test("SetPathIgnored(path, true) suppresses writes at runtime", function() + local manager = TableManager.new { debug = { counter = 0 } } + + local fired = 0 + manager:OnChange({ "debug" }, function() + fired += 1 + end) + + manager:Set({ "debug", "counter" }, 1) + expect(fired).is(1) -- not yet ignored + + manager:SetPathIgnored({ "debug" }, true) + manager:Set({ "debug", "counter" }, 2) + + expect(fired).is(1) -- unchanged: second write suppressed + expect(manager:Get { "debug", "counter" }).is(2) -- still written + + manager:Destroy() + end) + + test("an ignored descendant inside a larger write is pruned; sibling branches still fire", function() + local manager = TableManager.new { player = { hp = 100, debugCounter = 0 }, score = 0 } + manager:SetPathIgnored({ "player", "debugCounter" }, true) + + local hpFired = 0 + local debugFired = 0 + local scoreFired = 0 + manager:OnChange({ "player", "hp" }, function() + hpFired += 1 + end) + manager:OnChange({ "player", "debugCounter" }, function() + debugFired += 1 + end) + manager:OnChange({ "score" }, function() + scoreFired += 1 + end) + + -- Root replacement: a single write that touches every branch at once. + manager:Set({}, { player = { hp = 50, debugCounter = 999 }, score = 5 }) + + expect(hpFired).is(1) + expect(debugFired).is(0) + expect(scoreFired).is(1) + expect(manager:Get { "player", "debugCounter" }).is(999) -- still written + + manager:Destroy() + end) + + test("SetPathIgnored(path, false) re-enables firing", function() + local manager = TableManager.new { debug = { counter = 0 } } + manager:SetPathIgnored({ "debug" }, true) + + local fired = 0 + manager:OnChange({ "debug" }, function() + fired += 1 + end) + + manager:Set({ "debug", "counter" }, 1) + expect(fired).is(0) + + manager:SetPathIgnored({ "debug" }, false) + manager:Set({ "debug", "counter" }, 2) + + expect(fired).is(1) + expect(manager:Get { "debug", "counter" }).is(2) + + manager:Destroy() + end) + + test("ignoring the exact path of a value still allows reads to see writes", function() + local manager = TableManager.new { x = 1 } + manager:SetPathIgnored({ "x" }, true) + + local fired = 0 + manager:OnChange({ "x" }, function() + fired += 1 + end) + + manager:Set("x", 2) + manager:Set("x", 3) + + expect(fired).is(0) + expect(manager:Get("x")).is(3) + + manager:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.integration-scenarios.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.integration-scenarios.spec.luau new file mode 100644 index 00000000..204b8997 --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.integration-scenarios.spec.luau @@ -0,0 +1,79 @@ +--!strict + +return function(t: tiniest) + local TableManager = require("../../TableManager") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("Integration: Nested Change Flows", function() + test("handles complex nested structure with multiple changes", function() + local manager = TableManager.new { + game = { + world = { + players = { + { name = "Alice", health = 100 }, + { name = "Bob", health = 100 }, + }, + }, + }, + } + + local changeCount = 0 + local connection = manager:OnChange({ "game" }, function() + changeCount += 1 + end) + + manager.Proxy.game.world.players[1].health = 50 + manager.Proxy.game.world.players[2].health = 75 + manager:ArrayInsert({ "game", "world", "players" }, { name = "Charlie", health = 100 }) + + expect(changeCount >= 3).is_true() + + connection:Disconnect() + manager:Destroy() + end) + + test("maintains correct state after multiple array operations", function() + local manager = TableManager.new { + inventory = { "Sword" }, + } + + manager:ArrayInsert({ "inventory" }, "Shield") + manager:ArrayInsert({ "inventory" }, "Potion") + manager:ArrayRemove({ "inventory" }, 2) + + local firstItem: string = manager.Proxy.inventory[1] :: string + local secondItem: string = manager.Proxy.inventory[2] :: string + expect(firstItem).is("Sword") + expect(secondItem).is("Potion") + + manager:Destroy() + end) + + test("keeps listener ordering stable through mixed scalar and array updates", function() + local manager = TableManager.new { + profile = { + stats = { level = 1 }, + items = { "Wood" }, + }, + } + + local events = {} + manager:OnChange("profile", function(_newValue, _oldValue, metadata) + table.insert(events, metadata.OriginPath and table.concat(metadata.OriginPath, ".") or "nil") + end) + + manager.Proxy.profile.stats.level = 2 + manager:ArrayInsert({ "profile", "items" }, "Stone") + manager:ArrayRemove({ "profile", "items" }, 1) + + expect(#events >= 3).is_true() + expect(manager.Proxy.profile.stats.level).is(2) + expect(manager.Proxy.profile.items[1]).is("Stone") + + manager:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.lifecycle-methods.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.lifecycle-methods.spec.luau new file mode 100644 index 00000000..db9fac4a --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.lifecycle-methods.spec.luau @@ -0,0 +1,60 @@ +--!strict + +return function(t: tiniest) + local TableManager = require("../../TableManager") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("Method: Destroy", function() + test("can destroy manager with active listeners and signals", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local listenerFired = false + manager:OnValueChange({ "player", "health" }, function() + listenerFired = true + end) + + local signalConnection = manager.ValueChanged:Connect(function() + listenerFired = true + end) + + manager:Destroy() + signalConnection:Disconnect() + expect(listenerFired).never_is_true() + end) + + test("destroy after writes does not throw", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + manager.Proxy.player.health = 50 + + local ok = pcall(function() + manager:Destroy() + end) + + expect(ok).is_true() + end) + + test("destroy can be called twice safely", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local ok1 = pcall(function() + manager:Destroy() + end) + local ok2 = pcall(function() + manager:Destroy() + end) + + expect(ok1).is_true() + expect(ok2).is_true() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.link-fanout-perf.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.link-fanout-perf.spec.luau new file mode 100644 index 00000000..811c0d0e --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.link-fanout-perf.spec.luau @@ -0,0 +1,202 @@ +--!strict +--[[ + Tests for the linked fan-out optimizations (plan Part 2): + + - Phase A: a fanned-out write does NOT run a diff for a recipient that + observes nothing at that path (no listener/signal of its own would fire), + but the recipient's shadow is still kept current so a write IT later + originates fans out a correct oldValue. + - Phase B: a recipient that DOES observe replays the origin's already-built + diff node instead of re-diffing — so the whole group costs one `Diff.diff` + per write, not one per member. Gated to "no opacity active on either side"; + an opacity-active origin falls back to per-member re-diff, still correct. + + `Diff.diff` is monkeypatched to count real diff computations (ChangeDetector + indexes `Diff.diff` at call time, so replacing the field is observed). +]] + +return function(t: tiniest) + const TableManager = require("../../TableManager") + const Diff = require("../../Diff") + + const test = t.test + const describe = t.describe + const expect = t.expect + + -- Runs `fn`, returning how many times `Diff.diff` was called during it. + -- Always restores the original, even if `fn` throws. + const function countDiffsDuring(fn: () -> ()): number + const real = Diff.diff + local count = 0 + ;(Diff :: any).diff = function(...) + count += 1 + return real(...) + end + const ok, err = pcall(fn) + ;(Diff :: any).diff = real + if not ok then + error(err, 0) + end + return count + end + + describe("Linked fan-out: Phase A (skip unobserved recipients)", function() + test("a write to a slice no other member observes runs exactly ONE diff (origin only)", function() + const shared = { inv = { gold = 0 }, stats = { hp = 100 } } + const invMgr = TableManager.new(shared) + const statsMgr = TableManager.new(shared) + TableManager.Link({ { invMgr }, { statsMgr } }) + + local invFires, statsFires = 0, 0 + invMgr:OnChange("inv", function() + invFires += 1 + end) + statsMgr:OnChange("stats", function() + statsFires += 1 + end) + + const diffs = countDiffsDuring(function() + invMgr:Set("inv.gold", 5) + end) + + expect(diffs).is(1) -- origin diffs; statsMgr (observes only "stats") skips + expect(invFires).is(1) -- origin's own ancestor listener still fires + expect(statsFires).is(0) -- statsMgr observes nothing under "inv" + + invMgr:Destroy() + statsMgr:Destroy() + end) + + test("a skipped recipient's shadow stays current, so a write IT later originates fans out the right oldValue", function() + const shared = { hp = 100 } + const observer = TableManager.new(shared) + const silent = TableManager.new(shared) -- never registers a listener + TableManager.Link({ { observer }, { silent } }) + + local observerOld: any + observer:OnValueChange("hp", function(_newValue, oldValue) + observerOld = oldValue + end) + + -- observer writes; `silent` observes nothing → its fan-out diff is + -- skipped, but its shadow must still advance to 50 via Materialize. + observer:Set("hp", 50) + expect((silent :: any)._shadow:Get({ "hp" })).is(50) + + -- Now `silent` originates a write. Its _doFlush reads old from its own + -- shadow; if Phase A had wrongly skipped Materialize, this would be 100. + silent:Set("hp", 25) + expect(observerOld).is(50) -- correct prior value, not the stale 100 + + observer:Destroy() + silent:Destroy() + end) + + test("a recipient observing via an ANCESTOR listener is NOT skipped", function() + const shared = { nested = { deep = { val = 1 } } } + const a = TableManager.new(shared) + const b = TableManager.new(shared) + TableManager.Link({ { a }, { b } }) + + local bFires = 0 + b:OnChange("nested", function() -- ancestor of the changed leaf + bFires += 1 + end) + + a:Set("nested.deep.val", 9) + expect(bFires).is(1) -- ancestor coverage counts as observed + + a:Destroy() + b:Destroy() + end) + end) + + describe("Linked fan-out: Phase B (reuse origin's diff node)", function() + test("two members both observing one shared scalar cost ONE diff total", function() + const shared = { hp = 100 } + const a = TableManager.new(shared) + const b = TableManager.new(shared) + TableManager.Link({ { a }, { b } }) + + local aNew, aOld, bNew, bOld + a:OnValueChange("hp", function(newValue, oldValue) + aNew, aOld = newValue, oldValue + end) + b:OnValueChange("hp", function(newValue, oldValue) + bNew, bOld = newValue, oldValue + end) + + const diffs = countDiffsDuring(function() + a:Set("hp", 75) + end) + + expect(diffs).is(1) -- origin diffs once; b replays the node, no re-diff + expect(aNew).is(75) + expect(aOld).is(100) + expect(bNew).is(75) + expect(bOld).is(100) + + a:Destroy() + b:Destroy() + end) + + test("reused-node dispatch delivers granular descendant events to the recipient", function() + const shared = { profile = { name = "x", level = 1 } } + const a = TableManager.new(shared) + const b = TableManager.new(shared) + TableManager.Link({ { a }, { b } }) + + const bKeyChanges: { { key: any, new: any, old: any } } = {} + b:OnKeyChange("profile", function(key, newValue, oldValue) + table.insert(bKeyChanges, { key = key, new = newValue, old = oldValue }) + end) + + -- Replace the whole profile table with a structurally-different one; + -- the recipient must still receive the per-key change for `level`. + a:Set("profile", { name = "x", level = 2 }) + + local sawLevel = false + for _, c in bKeyChanges do + if c.key == "level" then + sawLevel = true + expect(c.new).is(2) + expect(c.old).is(1) + end + end + expect(sawLevel).is(true) + + a:Destroy() + b:Destroy() + end) + + test("opacity active on the origin disengages reuse (recipient re-diffs) and stays correct", function() + const shared = { flag = true } + const a = TableManager.new(shared) + const b = TableManager.new(shared) + TableManager.Link({ { a }, { b } }) + + -- Registering any opaque value in `a` activates a's opacity ctx + -- (`GetOpaqueCtx() ~= nil`), so a's fan-out can no longer hand its + -- diff node to `b` for reuse. (Done before b's listener exists, so + -- this write fans out to nothing.) + a:Set("blob", TableManager.Opaque({ stuff = 1 })) + + local bNew, bOld + b:OnValueChange("flag", function(newValue, oldValue) + bNew, bOld = newValue, oldValue + end) + + const diffs = countDiffsDuring(function() + a:Set("flag", false) + end) + + -- origin (1) + b's fallback re-diff (1): reuse disengaged. + expect(diffs).is(2) + expect(bNew).is(false) + expect(bOld).is(true) + + a:Destroy() + b:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.link.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.link.spec.luau new file mode 100644 index 00000000..4868a94d --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.link.spec.luau @@ -0,0 +1,540 @@ +--!strict + +return function(t: tiniest) + local TableManager = require("../../TableManager") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("TableManager.Link / LinkGroup", function() + test("whole-root link: writes on either side fire ValueChanged on the other", function() + local shared = { health = 100 } + local a = TableManager.new(shared) + local b = TableManager.new(shared) + + local group = TableManager.Link({ { a }, { b } }) + + local bPath, bNew, bOld + b:OnValueChange("health", function(newValue, oldValue) + bNew, bOld = newValue, oldValue + end) + + a:Set("health", 50) + + expect(b.Raw.health).is(50) + expect(bNew).is(50) + expect(bOld).is(100) + + local aNew + a:OnValueChange("health", function(newValue) + aNew = newValue + end) + b:Set("health", 25) + expect(a.Raw.health).is(25) + expect(aNew).is(25) + + expect(group:HasMember(a)).is_true() + expect(group:HasMember(b)).is_true() + + a:Destroy() + b:Destroy() + end) + + test("4-arg call form links two managers", function() + local shared = { value = 1 } + local a = TableManager.new(shared) + local b = TableManager.new(shared) + + TableManager.Link(a, "", b, "") + + local seen + b:OnValueChange("value", function(newValue) + seen = newValue + end) + a:Set("value", 2) + expect(seen).is(2) + + a:Destroy() + b:Destroy() + end) + + test("interior-anchor link: differing anchor paths translate correctly", function() + local sharedNode = { x = 1 } + local dataA = { B = sharedNode } + local dataB = { other = { nested = sharedNode } } + + local a = TableManager.new(dataA) + local b = TableManager.new(dataB) + + TableManager.Link({ { a, "B" }, { b, "other.nested" } }) + + local seenPath, seenNew + b:OnValueChange("other.nested.x", function(newValue) + seenNew = newValue + end) + + a:Set("B.x", 42) + + expect(b.Raw.other.nested.x).is(42) + expect(seenNew).is(42) + + -- And the reverse direction + local aSeen + a:OnValueChange("B.x", function(newValue) + aSeen = newValue + end) + b:Set("other.nested.x", 7) + expect(a.Raw.B.x).is(7) + expect(aSeen).is(7) + + a:Destroy() + b:Destroy() + end) + + test("array op fan-out: insert/remove/set propagate and keep proxy bookkeeping correct", function() + local shared = { items = { "A", "B", "C" } } + local a = TableManager.new(shared) + local b = TableManager.new(shared) + + TableManager.Link({ { a }, { b } }) + + -- Hold a proxy on b for the element currently at index 2 ("B") + local bProxyForB = b:GetProxy({ "items", 2 }) + + local insertedIndex, insertedValue + b:OnArrayInsert("items", function(index, newValue) + insertedIndex, insertedValue = index, newValue + end) + + a:ArrayInsert("items", 1, "X") + + expect(b.Raw.items[1]).is("X") + expect(insertedIndex).is(1) + expect(insertedValue).is("X") + -- b's held proxy for the original "B" element should have shifted to index 3 + expect((b:GetProxy({ "items", 3 }))).is(bProxyForB) + + local removedIndex, removedValue + b:OnArrayRemove("items", function(index, oldValue) + removedIndex, removedValue = index, oldValue + end) + + a:ArrayRemove("items", 1) -- removes "X" + + expect(b.Raw.items[1]).is("A") + expect(removedIndex).is(1) + expect(removedValue).is("X") + expect((b:GetProxy({ "items", 2 }))).is(bProxyForB) + + local setIndex, setNew, setOld + b:OnArraySet("items", function(index, newValue, oldValue) + setIndex, setNew, setOld = index, newValue, oldValue + end) + + -- items is currently {"A", "B", "C"}; swap-remove index 1 backfills + -- index 1 with "C" (ArraySet) and shrinks the array (ArrayRemoved). + a:ArraySwapRemove("items", 1) + + expect(b.Raw.items[1]).is("C") + expect(b.Raw.items[2]).is("B") + expect(b.Raw.items[3]).is(nil) + expect(setIndex).is(1) + expect(setNew).is("C") + expect(setOld).is("A") + -- b's held proxy for "B" (still at index 2) is unaffected by the swap-remove. + expect((b:GetProxy({ "items", 2 }))).is(bProxyForB) + + a:Destroy() + b:Destroy() + end) + + test("divergence: replacing the anchor object removes that member and dissolves a 2-member group", function() + local sharedNode = { x = 1 } + local dataA = { B = sharedNode } + local dataB = { B = sharedNode } + + local a = TableManager.new(dataA) + local b = TableManager.new(dataB) + + local group = TableManager.Link({ { a, "B" }, { b, "B" } }) + + local removedMember + group.MemberRemoved:Connect(function(manager) + removedMember = manager + end) + + local dissolved = false + group.Dissolved:Connect(function() + dissolved = true + end) + + -- Replace `B` on a's side entirely; this severs a's anchor. + a:Set("B", { x = 99 }) + + expect(removedMember).is(a) + expect(dissolved).is_true() + expect(group:HasMember(a)).never_is_true() + expect(group:HasMember(b)).never_is_true() + + -- b's data is untouched by the divergent write. + expect(b.Raw.B.x).is(1) + + a:Destroy() + b:Destroy() + end) + + test("divergence: replacing a member's root via Set({}, ...) severs it", function() + local shared = { health = 100 } + local a = TableManager.new(shared) + local b = TableManager.new(shared) + + local group = TableManager.Link({ { a }, { b } }) + + -- Re-root `a` onto a brand-new table; its root anchor no longer points + -- at the shared object, so it leaves the group. + a:Set({}, { health = 5 }) + + expect(group:HasMember(a)).never_is_true() + -- b keeps the original shared object, untouched by a's re-root. + expect(b.Raw.health).is(100) + expect(a.Raw.health).is(5) + + a:Destroy() + b:Destroy() + end) + + test("topology robustness: destroying the middle member of a 3-way group keeps the others linked", function() + local shared = { value = 1 } + local a = TableManager.new(shared) + local b = TableManager.new(shared) + local c = TableManager.new(shared) + + local group = TableManager.Link({ { a }, { b }, { c } }) + group:Add(c) -- idempotent re-add is a no-op (already linked via Link) + + b:Destroy() + + expect(group:HasMember(a)).is_true() + expect(group:HasMember(b)).never_is_true() + expect(group:HasMember(c)).is_true() + + local cSeen + c:OnValueChange("value", function(newValue) + cSeen = newValue + end) + + a:Set("value", 2) + + expect(c.Raw.value).is(2) + expect(cSeen).is(2) + + a:Destroy() + c:Destroy() + end) + + test("a throwing listener on one member does not block fan-out to the others", function() + local shared = { value = 1 } + local a = TableManager.new(shared) + local b = TableManager.new(shared) + local c = TableManager.new(shared) + + TableManager.Link({ { a }, { b }, { c } }) + + b:OnValueChange("value", function() + error("boom") + end) + + local cSeen + c:OnValueChange("value", function(newValue) + cSeen = newValue + end) + + local ok = pcall(function() + a:Set("value", 2) + end) + + expect(ok).is_true() + expect(c.Raw.value).is(2) + expect(cSeen).is(2) + + a:Destroy() + b:Destroy() + c:Destroy() + end) + + test("echo loop prevention: a single write fires the receiving manager's listener exactly once", function() + local shared = { value = 1 } + local a = TableManager.new(shared) + local b = TableManager.new(shared) + + TableManager.Link({ { a }, { b } }) + + local aFireCount, bFireCount = 0, 0 + a.ValueChanged:Connect(function() + aFireCount += 1 + end) + b.ValueChanged:Connect(function() + bFireCount += 1 + end) + + a:Set("value", 2) + + expect(aFireCount).is(1) + expect(bFireCount).is(1) + + a:Destroy() + b:Destroy() + end) + + test("Unlink removes membership from a specific group or all groups", function() + local shared = { value = 1 } + local a = TableManager.new(shared) + local b = TableManager.new(shared) + + local group = TableManager.Link({ { a }, { b } }) + + expect(#a.Linker:GetGroups()).is(1) + + a.Linker:Unlink(group) + + expect(#a.Linker:GetGroups()).is(0) + expect(group:HasMember(a)).never_is_true() + + a:Destroy() + b:Destroy() + end) + + test("Destroy unlinks a manager from all of its groups", function() + local shared = { value = 1 } + local a = TableManager.new(shared) + local b = TableManager.new(shared) + + local group = TableManager.Link({ { a }, { b } }) + + a:Destroy() + + expect(group:HasMember(a)).never_is_true() + expect(group:HasMember(b)).never_is_true() -- group dissolved below 2 members + + b:Destroy() + end) + end) + + describe("Linker facade", function() + test("Linker:With links whole roots; fan-out both directions", function() + local shared = { value = 1 } + local a = TableManager.new(shared) + local b = TableManager.new(shared) + + a.Linker:With(b) + + local seen + b:OnValueChange("value", function(newValue) + seen = newValue + end) + a:Set("value", 2) + expect(seen).is(2) + + local aSeen + a:OnValueChange("value", function(newValue) + aSeen = newValue + end) + b:Set("value", 3) + expect(aSeen).is(3) + + a:Destroy() + b:Destroy() + end) + + test("Linker:With links interior nodes via paths", function() + local sharedNode = { x = 1 } + local a = TableManager.new({ B = sharedNode }) + local b = TableManager.new({ other = { nested = sharedNode } }) + + a.Linker:With(b, "B", "other.nested") + + local seen + b:OnValueChange("other.nested.x", function(newValue) + seen = newValue + end) + a:Set("B.x", 42) + expect(b.Raw.other.nested.x).is(42) + expect(seen).is(42) + + a:Destroy() + b:Destroy() + end) + + test("IsLinkedWith and GetManagers report linked managers", function() + local shared = { value = 1 } + local a = TableManager.new(shared) + local b = TableManager.new(shared) + local c = TableManager.new(shared) + + TableManager.Link({ { a }, { b }, { c } }) + + expect(a.Linker:IsLinkedWith(b)).is_true() + expect(a.Linker:IsLinkedWith(c)).is_true() + + local managers = a.Linker:GetManagers() + expect(#managers).is(2) + -- excludes self + local containsSelf = false + for _, m in managers do + if m == a then + containsSelf = true + end + end + expect(containsSelf).never_is_true() + + a:Destroy() + b:Destroy() + c:Destroy() + end) + + test("Linker:Unlink(manager) severs the link to that manager", function() + local shared = { value = 1 } + local a = TableManager.new(shared) + local b = TableManager.new(shared) + + a.Linker:With(b) + expect(a.Linker:IsLinkedWith(b)).is_true() + + a.Linker:Unlink(b) + expect(a.Linker:IsLinkedWith(b)).never_is_true() + + a:Destroy() + b:Destroy() + end) + + test("tm.Linker is the same object across accesses", function() + local a = TableManager.new({ value = 1 }) + expect(a.Linker).is(a.Linker) + a:Destroy() + end) + end) + + describe("AutoLink", function() + test("nested managers auto-link to a parent (parent created first)", function() + local Data = { Inventory = { { Name = "Sword" } }, Stats = { Health = 100 } } + local PlayerTM = TableManager.new(Data, { AutoLink = true }) + local StatsTM = TableManager.new(Data.Stats, { AutoLink = true }) + local SwordTM = TableManager.new(Data.Inventory[1], { AutoLink = true }) + + local statsSeen + PlayerTM:OnValueChange("Stats.Health", function(newValue) + statsSeen = newValue + end) + StatsTM:Set("Health", 50) + expect(Data.Stats.Health).is(50) + expect(statsSeen).is(50) + + -- reverse direction + local healthOnStats + StatsTM:OnValueChange("Health", function(newValue) + healthOnStats = newValue + end) + PlayerTM:Set("Stats.Health", 75) + expect(healthOnStats).is(75) + + local swordSeen + PlayerTM:OnValueChange({ "Inventory", 1, "Name" }, function(newValue) + swordSeen = newValue + end) + SwordTM:Set("Name", "Axe") + expect(swordSeen).is("Axe") + + PlayerTM:Destroy() + StatsTM:Destroy() + SwordTM:Destroy() + end) + + test("nested manager auto-links when child created before parent", function() + local statsTable = { Health = 100 } + local StatsTM = TableManager.new(statsTable, { AutoLink = true }) + local PlayerTM = TableManager.new({ Stats = statsTable }, { AutoLink = true }) + + local seen + PlayerTM:OnValueChange("Stats.Health", function(newValue) + seen = newValue + end) + StatsTM:Set("Health", 25) + expect(seen).is(25) + + PlayerTM:Destroy() + StatsTM:Destroy() + end) + + test("direct insert of a managed table auto-links (no Ensure needed)", function() + local PlayerTM = TableManager.new({ Inventory = {} }, { AutoLink = true }) + local sword = { Name = "Sword" } + local SwordTM = TableManager.new(sword, { AutoLink = true }) + + PlayerTM:ArrayInsert("Inventory", sword) + + local seen + PlayerTM:OnValueChange({ "Inventory", 1, "Name" }, function(newValue) + seen = newValue + end) + SwordTM:Set("Name", "Mace") + expect(seen).is("Mace") + + PlayerTM:Destroy() + SwordTM:Destroy() + end) + + test("nested/bulk insert does not link until Linker:Ensure", function() + local PlayerTM = TableManager.new({}, { AutoLink = true }) + local sword = { Name = "Sword" } + local SwordTM = TableManager.new(sword, { AutoLink = true }) + + -- sword is nested one level inside the assigned composite value. + PlayerTM:Set("bag", { sword }) + expect(PlayerTM.Linker:IsLinkedWith(SwordTM)).never_is_true() + + PlayerTM.Linker:Ensure("bag") + expect(PlayerTM.Linker:IsLinkedWith(SwordTM)).is_true() + + local seen + PlayerTM:OnValueChange({ "bag", 1, "Name" }, function(newValue) + seen = newValue + end) + SwordTM:Set("Name", "Dagger") + expect(seen).is("Dagger") + + -- Ensure is idempotent + PlayerTM.Linker:Ensure("bag") + expect(#SwordTM.Linker:GetGroups()).is(1) + + PlayerTM:Destroy() + SwordTM:Destroy() + end) + + test("removing a nested managed table auto-severs the link", function() + local Data = { Inventory = { { Name = "Sword" } } } + local PlayerTM = TableManager.new(Data, { AutoLink = true }) + local SwordTM = TableManager.new(Data.Inventory[1], { AutoLink = true }) + + expect(PlayerTM.Linker:IsLinkedWith(SwordTM)).is_true() + + PlayerTM:ArrayRemove("Inventory", 1) + expect(PlayerTM.Linker:IsLinkedWith(SwordTM)).never_is_true() + + PlayerTM:Destroy() + SwordTM:Destroy() + end) + + test("without AutoLink, overlapping managers are not linked", function() + local Data = { Stats = { Health = 100 } } + local PlayerTM = TableManager.new(Data) + local StatsTM = TableManager.new(Data.Stats) + + expect(PlayerTM.Linker:IsLinkedWith(StatsTM)).never_is_true() + expect(#PlayerTM.Linker:GetGroups()).is(0) + + PlayerTM:Destroy() + StatsTM:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.listeners-methods.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.listeners-methods.spec.luau new file mode 100644 index 00000000..0ca3a878 --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.listeners-methods.spec.luau @@ -0,0 +1,423 @@ +--!strict + +return function(t: tiniest) + local TableManager = require("../../TableManager") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("Method: OnKeyAdd", function() + test("fires when a key is added", function() + local manager = TableManager.new { + player = { health = 100, mana = nil :: number? }, + } + + local fireCount = 0 + + manager:OnKeyAdd("player", function(key, newValue) + fireCount += 1 + expect(newValue).is(50) + expect(key).is("mana") + end) + + manager.Proxy.player.mana = 50 + + expect(fireCount).is(1) + + manager:Destroy() + end) + + test("respects ListenDepth = 0 for descendant adds", function() + local manager = TableManager.new { + player = { stats = { level = nil :: number? } }, + } + + local fireCount = 0 + manager:OnKeyAdd("player", function() + fireCount += 1 + end, { ListenDepth = 0 }) + + manager.Proxy.player.stats.level = 10 + expect(fireCount).is(0) + + manager:Destroy() + end) + + test("supports string path and matches KeyAdded signal payload", function() + local manager = TableManager.new { + player = { health = 100, energy = nil :: number? }, + } + + local listenerCount = 0 + local signalCount = 0 + local signalPath = nil + local signalKey = nil + local signalValue = nil + + manager:OnKeyAdd("player", function(key, newValue) + listenerCount += 1 + expect(newValue).is(25) + expect(key).is("energy") + end) + + local conn = manager.KeyAdded:Connect(function(path, key, value) + signalCount += 1 + signalPath = path + signalKey = key + signalValue = value + end) + + manager.Proxy.player.energy = 25 + + expect(listenerCount).is(1) + expect(signalCount).is(1) + expect(signalPath).is_shallow_equal { "player" } + expect(signalKey).is("energy") + expect(signalValue).is(25) + + conn:Disconnect() + manager:Destroy() + end) + end) + + describe("Method: OnKeyRemove", function() + test("fires when a key is removed", function() + local manager = TableManager.new { + player = { mana = 20 :: number? }, + } + + local fireCount = 0 + local removedKey = nil + local removedValue = nil + + manager:OnKeyRemove("player", function(key, value) + fireCount += 1 + removedKey = key + removedValue = value + end) + + manager.Proxy.player.mana = nil + + expect(fireCount).is(1) + expect(removedKey).is("mana") + expect(removedValue).is(20) + + manager:Destroy() + end) + + test("can remove a nested key under a root child", function() + local manager = TableManager.new { + settings = { volume = 80, quality = "high" :: string? }, + } + + local fireCount = 0 + manager:OnKeyRemove("settings", function() + fireCount += 1 + end) + + manager.Proxy.settings.quality = nil + expect(fireCount).is(1) + + manager:Destroy() + end) + + test("supports string path and matches KeyRemoved signal payload", function() + local manager = TableManager.new { + player = { shield = 30 :: number? }, + } + local managerAny: any = manager + + local listenerCount = 0 + local signalCount = 0 + local signalPath = nil + local signalKey = nil + local signalValue = nil + + managerAny:OnKeyRemove("player", function(oldValue) + listenerCount += 1 + expect(oldValue).is(30) + end) + + local conn = manager.KeyRemoved:Connect(function(path, key, oldValue) + signalCount += 1 + signalPath = path + signalKey = key + signalValue = oldValue + end) + + manager.Proxy.player.shield = nil + + expect(listenerCount).is(1) + expect(signalCount).is(1) + expect(signalPath).is_shallow_equal { "player" } + expect(signalKey).is("shield") + expect(signalValue).is(30) + + conn:Disconnect() + manager:Destroy() + end) + end) + + describe("Method: OnArrayInsert", function() + test("fires with index and value payload", function() + local manager = TableManager.new { + items = { "Sword" }, + } + + local fireCount = 0 + local seenIndex = nil + local seenValue = nil + + manager:OnArrayInsert("items", function(index, value) + fireCount += 1 + seenIndex = index + seenValue = value + end) + + manager:ArrayInsert("items", "Shield") + + expect(fireCount).is(1) + expect(seenIndex).is(2) + expect(seenValue).is("Shield") + + manager:Destroy() + end) + + test("supports proxy mutation target and ArrayInserted signal payload", function() + local manager = TableManager.new { + items = { "Sword", "Shield" }, + } + + local listenerCount = 0 + local signalCount = 0 + local signalPath = nil + local signalIndex = nil + local signalValue = nil + + manager:OnArrayInsert("items", function(index, value) + listenerCount += 1 + expect(index).is(3) + expect(value).is("Potion") + end) + + local conn = manager.ArrayInserted:Connect(function(path, index, value) + signalCount += 1 + signalPath = path + signalIndex = index + signalValue = value + end) + + manager:ArrayInsert(manager.Proxy.items, "Potion") + + expect(listenerCount).is(1) + expect(signalCount).is(1) + expect(signalPath).is_shallow_equal { "items" } + expect(signalIndex).is(3) + expect(signalValue).is("Potion") + + conn:Disconnect() + manager:Destroy() + end) + + test("only fires when the exact insert leaf path matches", function() + local manager = TableManager.new { + items = { "Sword" }, + } + + local mismatched = 0 + local matched = 0 + manager:OnArrayInsert("items", function(index, value) + if index == 2 then + matched += 1 + else + mismatched += 1 + end + end) + + manager:ArrayInsert("items", "Shield") + + expect(mismatched).is(0) + expect(matched).is(1) + + manager:Destroy() + end) + end) + + describe("Method: OnArrayRemove", function() + test("fires with index and oldValue payload", function() + local manager = TableManager.new { + items = { "Sword", "Shield" }, + } + + local fireCount = 0 + local seenIndex = nil + local seenValue = nil + + manager:OnArrayRemove("items", function(index, oldValue) + fireCount += 1 + seenIndex = index + seenValue = oldValue + end) + + manager:ArrayRemove({ "items" }, 1) + + expect(fireCount).is(1) + expect(seenIndex).is(1) + expect(seenValue).is("Sword") + + manager:Destroy() + end) + + test("supports proxy mutation target and ArrayRemoved signal payload", function() + local manager = TableManager.new { + items = { "Sword", "Shield", "Potion" }, + } + + local listenerCount = 0 + local signalCount = 0 + local signalPath = nil + local signalIndex = nil + local signalValue = nil + + manager:OnArrayRemove("items", function(index, oldValue) + listenerCount += 1 + expect(index).is(2) + expect(oldValue).is("Shield") + end) + + local conn = manager.ArrayRemoved:Connect(function(path, index, value) + signalCount += 1 + signalPath = path + signalIndex = index + signalValue = value + end) + + manager:ArrayRemove(manager.Proxy.items, 2) + + expect(listenerCount).is(1) + expect(signalCount).is(1) + expect(signalPath).is_shallow_equal { "items" } + expect(signalIndex).is(2) + expect(signalValue).is("Shield") + + conn:Disconnect() + manager:Destroy() + end) + + test("only fires when the exact remove leaf path matches", function() + local manager = TableManager.new { + items = { "Sword", "Shield", "Potion" }, + } + + local mismatched = 0 + local matched = 0 + manager:OnArrayRemove("items", function(index, oldValue) + if index == 2 then + matched += 1 + else + mismatched += 1 + end + end) + + manager:ArrayRemove("items", 2) + + expect(mismatched).is(0) + expect(matched).is(1) + + manager:Destroy() + end) + end) + + describe("Method: OnArraySet", function() + test("fires when ArraySwapRemove performs a set", function() + local manager = TableManager.new { + items = { "A", "B", "C" }, + } + + local fireCount = 0 + local seenIndex = nil + local seenNew = nil + local seenOld = nil + + manager:OnArraySet({ "items" }, function(index, newValue, oldValue) + fireCount += 1 + seenIndex = index + seenNew = newValue + seenOld = oldValue + end) + + manager:ArraySwapRemove({ "items" }, 1) + + expect(fireCount).is(1) + expect(seenIndex).is(1) + expect(seenNew).is("C") + expect(seenOld).is("A") + + manager:Destroy() + end) + + test("supports proxy mutation target and ArraySet signal payload", function() + local manager = TableManager.new { + items = { "A", "B", "C" }, + } + + local listenerCount = 0 + local signalCount = 0 + local signalPath = nil + local signalIndex = nil + local signalNew = nil + local signalOld = nil + + manager:OnArraySet("items", function(index, newValue, oldValue) + listenerCount += 1 + expect(index).is(1) + expect(newValue).is("C") + expect(oldValue).is("A") + end) + + local conn = manager.ArraySet:Connect(function(path, index, newValue, oldValue) + signalCount += 1 + signalPath = path + signalIndex = index + signalNew = newValue + signalOld = oldValue + end) + + manager:ArraySwapRemove(manager.Proxy.items, 1) + + expect(listenerCount).is(1) + expect(signalCount).is(1) + expect(signalPath).is_shallow_equal { "items" } + expect(signalIndex).is(1) + expect(signalNew).is("C") + expect(signalOld).is("A") + + conn:Disconnect() + manager:Destroy() + end) + + test("does not fire when swap-removing the last element", function() + local manager = TableManager.new { + items = { "A", "B" }, + } + + local listenerCount = 0 + local signalCount = 0 + manager:OnArraySet("items", function() + listenerCount += 1 + end) + local conn = manager.ArraySet:Connect(function() + signalCount += 1 + end) + + manager:ArraySwapRemove("items", 2) + + expect(listenerCount).is(0) + expect(signalCount).is(0) + + conn:Disconnect() + manager:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.mutation-methods.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.mutation-methods.spec.luau new file mode 100644 index 00000000..3fc03711 --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.mutation-methods.spec.luau @@ -0,0 +1,317 @@ +--!strict + +return function(t: tiniest) + local TableManager = require("../../TableManager") + local T = require("../../../T") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("Method: MoveTo", function() + test("supports proxy source and updates destination", function() + local manager = TableManager.new { + a = { value = 1 }, + b = { value = 2 }, + } + + manager:MoveTo(manager.Proxy.a, { "b" }) + + expect(manager.Proxy.a).is(nil) + expect(manager.Proxy.b.value).is(1) + + manager:Destroy() + end) + + test("rejects moving root", function() + local manager = TableManager.new { + a = { value = 1 }, + } + + expect(function() + manager:MoveTo({}, { "a" }) + end).fails_with("MoveTo cannot move the root table") + + manager:Destroy() + end) + + test("rejects moving into a descendant path", function() + local manager = TableManager.new { + a = { child = { value = 1 } }, + } + + expect(function() + manager:MoveTo({ "a" }, { "a", "child", "newPlace" }) + end).fails_with("MoveTo cannot move a table into one of its descendants") + + manager:Destroy() + end) + + test("rejects moving a non-table source", function() + local manager = TableManager.new { + a = 1, + b = { value = 2 }, + } + + expect(function() + manager:MoveTo({ "a" }, { "b" }) + end).fails_with("MoveTo source must be a table") + + manager:Destroy() + end) + end) + + describe("Method: CopyTo", function() + test("deep copies table data", function() + local manager = TableManager.new { + a = { nested = { value = 1 } }, + b = { nested = { value = 99 } }, + } + + manager:CopyTo({ "a" }, { "b" }) + + expect(manager.Proxy.b.nested.value).is(1) + expect(manager.Proxy.b).is_not(manager.Proxy.a) + + manager.Proxy.b.nested.value = 77 + expect(manager.Proxy.a.nested.value).is(1) + expect(manager.Proxy.b.nested.value).is(77) + + manager:Destroy() + end) + + test("supports proxy source", function() + local manager = TableManager.new { + a = { value = 1 }, + b = { value = 2 }, + } + + manager:CopyTo(manager.Proxy.a, { "b" }) + + expect(manager.Proxy.a.value).is(1) + expect(manager.Proxy.b.value).is(1) + expect(manager.Proxy.b).is_not(manager.Proxy.a) + + manager:Destroy() + end) + + test("rejects root destination", function() + local manager = TableManager.new { + a = { value = 1 }, + } + + expect(function() + manager:CopyTo({ "a" }, {}) + end).fails_with("CopyTo cannot set the root table") + + manager:Destroy() + end) + end) + + describe("Method: Swap", function() + test("swaps scalar values", function() + local manager = TableManager.new { + a = 1, + b = 2, + } + + manager:Swap({ "a" }, { "b" }) + + expect(manager.Proxy.a).is(2) + expect(manager.Proxy.b).is(1) + + manager:Destroy() + end) + + test("swaps table values and preserves live proxy identity", function() + local manager = TableManager.new { + a = { value = 10 }, + b = { value = 20 }, + } + + local heldA = manager.Proxy.a + local heldB = manager.Proxy.b + + manager:Swap({ "a" }, { "b" }) + + expect(manager.Proxy.a.value).is(20) + expect(manager.Proxy.b.value).is(10) + expect(manager.Proxy.b).is(heldA) + expect(manager.Proxy.a).is(heldB) + + heldA.value = 111 + expect(manager.Proxy.b.value).is(111) + + manager:Destroy() + end) + + test("supports proxy arguments", function() + local manager = TableManager.new { + a = { value = 10 }, + b = { value = 20 }, + } + + manager:Swap(manager.Proxy.a, manager.Proxy.b) + + expect(manager.Proxy.a.value).is(20) + expect(manager.Proxy.b.value).is(10) + + manager:Destroy() + end) + + test("rejects root path", function() + local manager = TableManager.new { + a = 1, + } + + expect(function() + manager:Swap({}, { "a" }) + end).fails_with("Swap cannot target the root table") + + manager:Destroy() + end) + + test("rejects ancestor and descendant paths", function() + local manager = TableManager.new { + a = { child = { value = 1 } }, + } + + expect(function() + manager:Swap({ "a" }, { "a", "child" }) + end).fails_with("Swap cannot swap ancestor/descendant paths") + + manager:Destroy() + end) + end) + + describe("Method: Set (root replacement)", function() + test("replaces the entire root table contents", function() + local manager = TableManager.new { + a = 1, + b = { value = 2 }, + } + + manager:Set({}, { a = 10, c = 3 }) + + expect(manager:Get("a")).is(10) + expect(manager:Get("b")).is(nil) + expect(manager:Get("c")).is(3) + + manager:Destroy() + end) + + test("preserves manager.Proxy identity and reads new data", function() + local manager = TableManager.new { + a = 1, + } + local heldProxy = manager.Proxy + + manager:Set({}, { a = 2, nested = { v = 5 } }) + + expect(manager.Proxy).is(heldProxy) + expect(heldProxy.a).is(2) + expect(heldProxy.nested.v).is(5) + expect(manager.Raw).is(manager:Get({})) + + manager:Destroy() + end) + + test("fires root key listeners for added and removed keys", function() + local manager = TableManager.new { + keep = 1, + drop = 2, + } + + local addedValues = {} + local removedValues = {} + local rootChanges = 0 + + local addConn = manager:OnKeyAdd({}, function(key, newValue) + table.insert(addedValues, newValue) + end) + local removeConn = manager:OnKeyRemove({}, function(key, oldValue) + table.insert(removedValues, oldValue) + end) + local changeConn = manager:OnChange({}, function() + rootChanges += 1 + end) + + manager:Set({}, { keep = 10, fresh = 3 }) + + expect(table.find(addedValues, 3)).exists() + expect(table.find(removedValues, 2)).exists() + expect(rootChanges > 0).is(true) + + addConn:Disconnect() + removeConn:Disconnect() + changeConn:Disconnect() + manager:Destroy() + end) + + test("is a no-op when setting the same root table", function() + local manager = TableManager.new { + a = 1, + } + + local changes = 0 + local conn = manager:OnChange({}, function() + changes += 1 + end) + + manager:Set({}, manager.Raw) + + expect(changes).is(0) + + conn:Disconnect() + manager:Destroy() + end) + + test("rejects setting the root to nil", function() + local manager = TableManager.new { + a = 1, + } + + expect(function() + manager:Set({}, nil :: any) + end).fails_with("root must remain a table") + + manager:Destroy() + end) + + test("rejects replacing the root inside a batch", function() + local manager = TableManager.new { + a = 1, + } + + expect(function() + manager:Batch(function() + manager:Set({}, { a = 2 }) + end) + end).fails_with("Cannot replace the root table inside a batch") + + manager:Destroy() + end) + + test("enforces the root schema and rejects invalid replacements", function() + local manager = TableManager.new({ + count = 1, + }, { + Schema = T.strictInterface { + count = T.number, + }, + }) + + expect(function() + manager:Set({}, { count = "nope" }) + end).fails() + + -- The rejected write must not have mutated the root. + expect(manager:Get("count")).is(1) + + manager:Set({}, { count = 5 }) + expect(manager:Get("count")).is(5) + + manager:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.opaque.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.opaque.spec.luau new file mode 100644 index 00000000..4011194e --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.opaque.spec.luau @@ -0,0 +1,296 @@ +--!strict +--[[ + Integration coverage for `Opaque`/`GlobalOpaque`/`OpaqueChildren`/ + `GlobalOpaqueChildren`: identity-based opacity marking, the diff + suppression it buys inside Batch/Suspend, and the "opacity only + short-circuits ANCESTOR diffs, never an explicit write to a descendant" + contract. +]] + +return function(t: tiniest) + local TableManager = require("../../TableManager") + local GcHelpers = require("../Helpers/GcHelpers") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("Opaque", function() + test("Set(path, Opaque(obj)) stores the bare object; Get/Raw return it directly", function() + local manager = TableManager.new { player = {} } + local obj = { hp = 100 } + + manager:Set("player", TableManager.Opaque(obj)) + + expect(manager:Get("player")).is(obj) + expect(manager.Raw.player).is(obj) + + manager:Destroy() + end) + + test("raw mutation outside the API fires nothing, opaque or not (existing contract)", function() + local manager = TableManager.new { player = {}, control = {} } + local obj = { hp = 100 } + local controlObj = { hp = 100 } + manager:Set("player", TableManager.Opaque(obj)) + manager:Set("control", controlObj) + + local fired = 0 + manager.ValueChanged:Connect(function() + fired += 1 + end) + + obj.hp = 1 -- direct Lua mutation, bypasses the API entirely + controlObj.hp = 1 + + expect(fired).is(0) + + manager:Destroy() + end) + + test("mutation via proxy inside Batch is suppressed for Opaque but fires for a non-opaque control", function() + local manager = TableManager.new { player = {}, control = {} } + local obj = { hp = 100 } + local controlObj = { hp = 100 } + manager:Set("player", TableManager.Opaque(obj)) + manager:Set("control", controlObj) + + local playerChanges = 0 + local controlChanges = 0 + manager:OnChange({ "player" }, function() + playerChanges += 1 + end) + manager:OnChange({ "control" }, function() + controlChanges += 1 + end) + + manager:Batch(function() + manager.Proxy.player.hp = 50 + manager.Proxy.control.hp = 50 + end) + + expect(playerChanges).is(0) + expect(controlChanges).is(1) + + manager:Destroy() + end) + + test("mutation via proxy inside Suspend/Resume is suppressed for Opaque but fires for a non-opaque control", function() + local manager = TableManager.new { player = {}, control = {} } + local obj = { hp = 100 } + local controlObj = { hp = 100 } + manager:Set("player", TableManager.Opaque(obj)) + manager:Set("control", controlObj) + + local playerChanges = 0 + local controlChanges = 0 + manager:OnChange({ "player" }, function() + playerChanges += 1 + end) + manager:OnChange({ "control" }, function() + controlChanges += 1 + end) + + manager:Suspend() + manager.Proxy.player.hp = 50 + manager.Proxy.control.hp = 50 + manager:Resume() + + expect(playerChanges).is(0) + expect(controlChanges).is(1) + + manager:Destroy() + end) + + test("replacing the object fires exactly one signal, not a flood of descendant events", function() + local manager = TableManager.new { player = {} } + manager:Set("player", TableManager.Opaque({ hp = 100, stats = { str = 1, dex = 2 } })) + + local fired = 0 + manager.ValueChanged:Connect(function() + fired += 1 + end) + + manager:Set("player", { hp = 1, stats = { str = 9, dex = 9, luck = 9 } }) + + expect(fired).is(1) + + manager:Destroy() + end) + + test("control: replacing a non-opaque object DOES flood descendant events", function() + local manager = TableManager.new { player = {} } + manager:Set("player", { hp = 100, stats = { str = 1, dex = 2 } }) + + local fired = 0 + manager.ValueChanged:Connect(function() + fired += 1 + end) + + manager:Set("player", { hp = 1, stats = { str = 9, dex = 9, luck = 9 } }) + + expect(fired > 1).is_true() + + manager:Destroy() + end) + + test("a direct write to a descendant path of an opaque object still fires (only ancestor diffs are short-circuited)", function() + local manager = TableManager.new { player = {} } + manager:Set("player", TableManager.Opaque({ hp = 100 })) + + local newHp + manager:OnValueChange({ "player", "hp" }, function(value) + newHp = value + end) + + manager:Set({ "player", "hp" }, 50) + + expect(newHp).is(50) + expect(manager:Get { "player", "hp" }).is(50) + + manager:Destroy() + end) + + test("GlobalOpaque marks the object opaque for every manager, not just the one that wrapped it", function() + local shared = { hp = 100 } + local managerA = TableManager.new { shared = {} } + local managerB = TableManager.new { shared = {} } + + managerA:Set("shared", TableManager.GlobalOpaque(shared)) + managerB:Set("shared", shared) -- same ref, NOT re-wrapped on this manager + + local changesB = 0 + managerB:OnChange({ "shared" }, function() + changesB += 1 + end) + + managerB:Batch(function() + managerB.Proxy.shared.hp = 1 + end) + + expect(changesB).is(0) + + managerA:Destroy() + managerB:Destroy() + end) + + test("MoveTo preserves the opaque mark (shared identity)", function() + local manager = TableManager.new { a = {}, c = {} } + local obj = { hp = 100 } + manager:Set("a", TableManager.Opaque(obj)) + + manager:MoveTo({ "a" }, { "c" }) + expect(manager:Get("c")).is(obj) + + local fired = 0 + manager:OnChange({ "c" }, function() + fired += 1 + end) + manager:Batch(function() + manager.Proxy.c.hp = 1 + end) + + expect(fired).is(0) -- still opaque: same ref, still registered + + manager:Destroy() + end) + + test("CopyTo produces a fresh ref that is NOT opaque until re-marked", function() + local manager = TableManager.new { a = {}, b = {} } + local obj = { hp = 100 } + manager:Set("a", TableManager.Opaque(obj)) + + manager:CopyTo({ "a" }, { "b" }) + expect(manager:Get("b")).never_is(obj) -- CopyTo deep-clones: fresh identity + + local fired = 0 + manager:OnChange({ "b" }, function() + fired += 1 + end) + manager:Batch(function() + manager.Proxy.b.hp = 1 + end) + + expect(fired).is(1) -- the copy was never registered, so it's not opaque + + manager:Destroy() + end) + + test("works without a proxy graph (EnableProxies = false)", function() + local manager = TableManager.new({ player = {} }, { EnableProxies = false }) + local obj = { hp = 100 } + manager:Set("player", TableManager.Opaque(obj)) + + expect(manager:Get("player")).is(obj) + + local fired = 0 + manager:OnChange({ "player" }, function() + fired += 1 + end) + + manager:Batch(function() + manager:Set({ "player", "hp" }, 1) -- same `player` ref throughout; mutates "in place" + end) + + expect(fired).is(0) + + manager:Destroy() + end) + + test("Destroy releases the per-manager opacity registry", function() + local manager = TableManager.new { player = {} } + local isAlive: () -> boolean + + do + local obj = { hp = 100 } + isAlive = GcHelpers.WeakProbe(obj) + manager:Set("player", TableManager.Opaque(obj)) + end + + local registries = (manager :: any)._opaqueRegistries + manager:Set("player", nil :: any) -- drop the manager's own strong ref to obj + manager:Destroy() + + local collected = GcHelpers.WaitForCollection(isAlive) + expect(collected).is_true() + expect(GcHelpers.CountKeys(registries.Opaque)).is(0) + end) + end) + + describe("OpaqueChildren", function() + test("ArrayInsert/ArrayRemove fire normally; an inserted element's internal mutation in a Batch is suppressed", function() + local manager = TableManager.new { items = {} } + manager:Set("items", TableManager.OpaqueChildren({})) + + local inserted, removed = 0, 0 + manager.ArrayInserted:Connect(function() + inserted += 1 + end) + manager.ArrayRemoved:Connect(function() + removed += 1 + end) + + local elem = { hp = 100 } + manager:ArrayInsert("items", elem) -- inserted AFTER marking: auto-opaque + + expect(inserted).is(1) + expect(manager:Get { "items", 1 }).is(elem) + + local elementChanges = 0 + manager:OnChange({ "items", 1 }, function() + elementChanges += 1 + end) + + manager:Batch(function() + manager.Proxy.items[1].hp = 1 + end) + + expect(elementChanges).is(0) -- internal mutation suppressed: auto-opaque child + + manager:ArrayRemove("items", 1) + expect(removed).is(1) + + manager:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.path-helper-methods.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.path-helper-methods.spec.luau new file mode 100644 index 00000000..951c2a58 --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.path-helper-methods.spec.luau @@ -0,0 +1,268 @@ +--!strict + +return function(t: tiniest) + local TableManager = require("../../TableManager") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("Method: Get", function() + test("returns nested values by table path", function() + local manager = TableManager.new { + game = { world = { region = { zone = 1 } } }, + } + + local zone = manager:Get { "game", "world", "region", "zone" } + expect(zone).is(1) + + manager:Destroy() + end) + + test("supports dot-string path input", function() + local manager = TableManager.new { + player = { stats = { health = 100 } }, + } + + local health = manager:Get("player.stats.health") + expect(health).is(100) + + manager:Destroy() + end) + + test("errors for non-table intermediate segment", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + expect(function() + manager:Get { "player", "health", "x" } + end).fails_with("Path segment x is not a table") + + manager:Destroy() + end) + + test("returns nil when suppressNilPartialPaths is true", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local result = manager:Get({ "player", "health", "x" }, true) + expect(result).is(nil) + + manager:Destroy() + end) + + test("allows nil values at the end of the path", function() + local manager = TableManager.new { + player = { health = nil }, + } + + local result = manager:Get { "player", "health" } + expect(result).is(nil) + + manager:Destroy() + end) + end) + + describe("Method: GetProxy", function() + test("returns a proxy for table paths and scalar for value paths", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local playerProxy = manager:GetProxy { "player" } + local healthValue = manager:GetProxy { "player", "health" } + + expect(playerProxy).exists() + expect(healthValue).is(100) + + manager:Destroy() + end) + + test("supports dot-string path input via ParsePath", function() + local manager = TableManager.new { + player = { inventory = { slots = 12 } }, + } + + local value = manager:GetProxy("player.inventory.slots") + expect(value).is(12) + + manager:Destroy() + end) + + test("errors for non-table intermediate segment", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + expect(function() + manager:GetProxy { "player", "mana", "x" } + end).fails_with("Path segment mana is not a table") + + manager:Destroy() + end) + + test("returns nil when suppressNilPartialPaths is true", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local result = manager:GetProxy({ "player", "mana", "x" }, true) + expect(result).is(nil) + + manager:Destroy() + end) + + test("allows nil values at the end of the path", function() + local manager = TableManager.new { + player = { health = nil }, + } + + local result = manager:GetProxy { "player", "health" } + expect(result).is(nil) + + manager:Destroy() + end) + end) + + describe("Method: Set", function() + test("sets nested values by path", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + manager:Set({ "player", "health" }, 75) + expect(manager.Proxy.player.health).is(75) + + manager:Destroy() + end) + + test("proxy held before a subtree replacement stays bound to the old table", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local heldPlayer = manager.Proxy.player + + manager:Set({ "player" }, { health = 50 }) + expect(manager.Proxy.player.health).is(50) + + -- heldPlayer is bound to the replaced (now detached) table, not the + -- "player" path - writing through it must not affect the live tree. + heldPlayer.health = 999 + expect(manager.Proxy.player.health).is(50) + expect(manager:Get { "player", "health" }).is(50) + + manager:Destroy() + end) + + test("supports dot-string path input", function() + local manager = TableManager.new { + player = { stats = { health = 100 } }, + } + manager:Set("player.stats.health", 60) + expect(manager.Proxy.player.stats.health).is(60) + + manager:Destroy() + end) + + test("triggers ValueChanged and KeyChanged signals once", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local valueCount = 0 + local keyCount = 0 + local valuePath = nil + local keyPath = nil + local keyName = nil + + local vConn = manager.ValueChanged:Connect(function(path) + valueCount += 1 + valuePath = path + end) + local kConn = manager.KeyChanged:Connect(function(path, key) + keyCount += 1 + keyPath = path + keyName = key + end) + + manager:Set({ "player", "health" }, 50) + + expect(valueCount).is(1) + expect(keyCount).is(1) + expect(valuePath).is_shallow_equal { "player", "health" } + expect(keyPath).is_shallow_equal { "player" } + expect(keyName).is("health") + + vConn:Disconnect() + kConn:Disconnect() + manager:Destroy() + end) + + test("errors when setting the root to a non-table value", function() + local manager = TableManager.new { + a = 1, + } + + expect(function() + manager:Set({}, 2) + end).fails_with("root must remain a table") + + manager:Destroy() + end) + + test("errors for non-table intermediate segments", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + expect(function() + manager:Set({ "player", "health", "value" }, 10) + end).fails_with("Path segment health is not a table") + + manager:Destroy() + end) + + test("nil write through a non-table segment silently succeeds", function() + -- A nil write is a removal, and a scalar segment means the target + -- path holds nothing - there is nothing to remove, so this must be + -- a silent no-op (replication consumers can receive a redundant + -- child removal after the parent was replaced by a scalar). + local manager = TableManager.new { + player = { health = 100 }, + } + + local fired = 0 + manager.ValueChanged:Connect(function() + fired += 1 + end) + + manager:Set({ "player", "health", "buff" }, nil) + + expect(fired).is(0) + expect(manager:Get { "player", "health" }).is(100) + + manager:Destroy() + end) + + test("nil write through a missing segment silently succeeds", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local fired = 0 + manager.ValueChanged:Connect(function() + fired += 1 + end) + + manager:Set({ "player", "gear", "slot" }, nil) + + expect(fired).is(0) + expect(manager:Get({ "player", "gear" }, true)).is(nil) + + manager:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.proxyless.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.proxyless.spec.luau new file mode 100644 index 00000000..7c7bf2d9 --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.proxyless.spec.luau @@ -0,0 +1,331 @@ +--!strict +--[[ + Coverage for `Config.EnableProxies = false`: every explicit API surface + (Get/Set/array ops/MoveTo/CopyTo/Swap/Batch/Suspend/Resume/listeners/ + ForceNotify) must work identically without a proxy graph, `Proxy`/`GetProxy` + must be unavailable, and replication fidelity must hold. +]] + +return function(t: tiniest) + local TableManager = require("../../TableManager") + local ReplicationHarness = require("../Helpers/ReplicationHarness") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("EnableProxies = false", function() + test("Proxy is nil and GetProxy errors", function() + local manager = TableManager.new({ player = { health = 100 } }, { EnableProxies = false }) + + expect(manager.Proxy).is(nil) + expect(function() + manager:GetProxy { "player" } + end).fails_with("GetProxy requires proxies %(Config%.EnableProxies = false for this TableManager%)") + + manager:Destroy() + end) + + test("Get/Set work without proxies", function() + local manager = TableManager.new({ player = { health = 100 } }, { EnableProxies = false }) + + expect(manager:Get { "player", "health" }).is(100) + + manager:Set({ "player", "health" }, 75) + expect(manager:Get { "player", "health" }).is(75) + + manager:Destroy() + end) + + test("Set({}, newTable) replaces the root without proxies", function() + local manager = TableManager.new({ a = 1, b = 2 }, { EnableProxies = false }) + + local rootChanges = 0 + local conn = manager:OnChange({}, function() + rootChanges += 1 + end) + + manager:Set({}, { a = 10, c = 3 }) + + expect(manager:Get("a")).is(10) + expect(manager:Get("b")).is(nil) + expect(manager:Get("c")).is(3) + expect(manager.Raw).is(manager:Get({})) + expect(rootChanges > 0).is(true) + + conn:Disconnect() + manager:Destroy() + end) + + test("Set with buildTablesDynamically constructs missing subtrees", function() + local manager = TableManager.new({}, { EnableProxies = false }) + + manager:Set({ "a", "b", "c" }, 5, true) + expect(manager:Get { "a", "b", "c" }).is(5) + + manager:Destroy() + end) + + test("Set with a nil value on a missing path is a silent no-op", function() + local manager = TableManager.new({}, { EnableProxies = false }) + + manager:Set({ "missing", "leaf" }, nil) + expect(manager:Get({ "missing" }, true)).is(nil) + + manager:Destroy() + end) + + test("Set appending to an array fires ArrayInserted, not KeyAdded", function() + local manager = TableManager.new({ items = { "A", "B" } }, { EnableProxies = false }) + + local insertedCount = 0 + local keyAddedCount = 0 + local insertedConn = manager.ArrayInserted:Connect(function(_path, index, newValue) + insertedCount += 1 + expect(index).is(3) + expect(newValue).is("C") + end) + local keyAddedConn = manager.KeyAdded:Connect(function() + keyAddedCount += 1 + end) + + manager:Set({ "items", 3 }, "C") + + expect(insertedCount).is(1) + expect(keyAddedCount).is(0) + expect(manager:Get { "items", 3 }).is("C") + + insertedConn:Disconnect() + keyAddedConn:Disconnect() + manager:Destroy() + end) + + test("all array operations work without proxies", function() + local manager = TableManager.new({ items = { "A", "B", "C" } }, { EnableProxies = false }) + + manager:ArrayInsert({ "items" }, "D") + expect(manager:Get { "items", 4 }).is("D") + + manager:ArrayInsert({ "items" }, 1, "Z") + expect(manager:Get { "items", 1 }).is("Z") + + local removed = manager:ArrayRemove({ "items" }, 1) + expect(removed).is("Z") + expect(manager:Get { "items", 1 }).is("A") + + local foundIndex = manager:ArrayRemoveFirstValue({ "items" }, "B") + expect(foundIndex).is(2) + + local swapRemoved = manager:ArraySwapRemove({ "items" }, 1) + expect(swapRemoved).is("A") + + manager:ArrayInsert({ "items" }, "B") + local swapFoundIndex = manager:ArraySwapRemoveFirstValue({ "items" }, "B") + expect(swapFoundIndex).exists() + + manager:Destroy() + end) + + test("MoveTo, CopyTo, and Swap work without proxies", function() + local manager = TableManager.new({ + a = { value = 1 }, + b = { value = 2 }, + c = nil :: any, + }, { EnableProxies = false }) + + manager:MoveTo({ "a" }, { "c" }) + expect(manager:Get({ "a" }, true)).is(nil) + expect(manager:Get { "c", "value" }).is(1) + + manager:CopyTo({ "b" }, { "d" }) + expect(manager:Get { "d", "value" }).is(2) + expect(manager:Get { "b", "value" }).is(2) + + manager:Swap({ "c" }, { "d" }) + expect(manager:Get { "c", "value" }).is(2) + expect(manager:Get { "d", "value" }).is(1) + + manager:Destroy() + end) + + test("Batch/Suspend/Resume coalesce array operations without proxies", function() + local manager = TableManager.new({ items = { "A", "B", "C" } }, { EnableProxies = false }) + + local insertedCount = 0 + local removedCount = 0 + local conn1 = manager.ArrayInserted:Connect(function() + insertedCount += 1 + end) + local conn2 = manager.ArrayRemoved:Connect(function() + removedCount += 1 + end) + + manager:Batch(function() + manager:ArrayInsert({ "items" }, "D") + manager:ArrayRemove({ "items" }, 1) + end) + + expect(insertedCount).is(1) + expect(removedCount).is(1) + expect(manager:Get { "items" }).is_shallow_equal { "B", "C", "D" } + + conn1:Disconnect() + conn2:Disconnect() + manager:Destroy() + end) + + test("OnChange/OnValueChange listeners and ForceNotify work without proxies", function() + local manager = TableManager.new({ player = { health = 100 } }, { EnableProxies = false }) + + local changeCount = 0 + local conn = manager:OnValueChange({ "player", "health" }, function(newValue, oldValue) + changeCount += 1 + expect(newValue).is(100) + expect(oldValue).is(100) + end) + + manager:ForceNotify { "player", "health" } + expect(changeCount).is(1) + + conn:Disconnect() + manager:Destroy() + end) + + test("DuplicateReferenceMode alongside disabled proxies does not error", function() + local ok = pcall(function() + local manager = TableManager.new( + { player = { health = 100 } }, + { EnableProxies = false, DuplicateReferenceMode = "warn" } + ) + manager:Destroy() + end) + + expect(ok).is_true() + end) + + test("replication fidelity holds with a proxyless source manager", function() + local harness = ReplicationHarness.new({ + player = { health = 100, items = { "A", "B" } }, + }, "signals", { EnableProxies = false }) + harness:Connect() + + expect(harness:Step(function() + harness.Source:Set({ "player", "health" }, 75) + end)).is_true() + + expect(harness:Step(function() + harness.Source:ArrayInsert({ "player", "items" }, "C") + end)).is_true() + + expect(harness:Step(function() + harness.Source:ArrayRemove({ "player", "items" }, 1) + end)).is_true() + + expect(harness:IsConverged()).is_true() + expect(harness:EchoMatches()).is_true() + + harness:Destroy() + end) + + -- These exercise `TableManager._NotifyApplied` (the LinkGroup fan-out replay + -- path) when `_proxyManager == nil`. The proxy-key-shift step must no-op + -- cleanly on a proxyless receiver while the op still converges and re-fires + -- the receiver's listeners/signals. + test("linked proxyless managers fan out a scalar Set", function() + local shared = { player = { health = 100 } } + local a = TableManager.new(shared, { EnableProxies = false }) + local b = TableManager.new(shared, { EnableProxies = false }) + + TableManager.Link({ { a }, { b } }) + + local bNew, bOld + b:OnValueChange({ "player", "health" }, function(newValue, oldValue) + bNew, bOld = newValue, oldValue + end) + + a:Set({ "player", "health" }, 50) + + expect(b.Raw.player.health).is(50) + expect(bNew).is(50) + expect(bOld).is(100) + + a:Destroy() + b:Destroy() + end) + + test("linked proxyless managers fan out array insert/remove/set", function() + local shared = { items = { "A", "B", "C" } } + local a = TableManager.new(shared, { EnableProxies = false }) + local b = TableManager.new(shared, { EnableProxies = false }) + + TableManager.Link({ { a }, { b } }) + + local inserted = {} + local removed = {} + local setOps = {} + b:OnArrayInsert({ "items" }, function(index, newValue) + table.insert(inserted, { index, newValue }) + end) + b:OnArrayRemove({ "items" }, function(index, oldValue) + table.insert(removed, { index, oldValue }) + end) + b:OnArraySet({ "items" }, function(index, newValue, oldValue) + table.insert(setOps, { index, newValue, oldValue }) + end) + + -- ArrayInsert fan-out hits the "ArrayInsert" branch of _NotifyApplied. + a:ArrayInsert({ "items" }, 1, "X") + expect(b:Get { "items" }).is_shallow_equal { "X", "A", "B", "C" } + expect(#inserted).is(1) + expect(inserted[1][1]).is(1) + expect(inserted[1][2]).is("X") + + -- ArrayRemove fan-out hits the "ArrayRemove" branch. + a:ArrayRemove({ "items" }, 2) + expect(b:Get { "items" }).is_shallow_equal { "X", "B", "C" } + expect(#removed).is(1) + expect(removed[1][1]).is(2) + + -- ArraySwapRemove emits an ArraySet (backfill) + ArrayRemoved, exercising + -- the "ArraySet" branch on the proxyless receiver. + a:ArraySwapRemove({ "items" }, 1) + expect(b:Get { "items" }).is_shallow_equal { "C", "B" } + expect(#setOps).is(1) + expect(setOps[1][1]).is(1) + expect(setOps[1][2]).is("C") + + a:Destroy() + b:Destroy() + end) + + test("mixed proxied + proxyless link fans out array ops both directions", function() + local shared = { items = { "A", "B" } } + local proxied = TableManager.new(shared) + local proxyless = TableManager.new(shared, { EnableProxies = false }) + + TableManager.Link({ { proxied }, { proxyless } }) + + -- The proxied side holds a proxy that must stay coherent after an op that + -- originates on the proxyless side fans out and runs ShiftKeys on the + -- proxied receiver. + local heldProxy = proxied:GetProxy { "items", 2 } -- "B" + + proxyless:ArrayInsert({ "items" }, 1, "X") + expect(proxied:Get { "items" }).is_shallow_equal { "X", "A", "B" } + expect((proxied:GetProxy { "items", 3 })).is(heldProxy) + + -- Reverse: an op on the proxied side fans out to the proxyless receiver, + -- whose key-shift is a clean no-op. + local proxylessInserted + proxyless:OnArrayInsert({ "items" }, function(_index, newValue) + proxylessInserted = newValue + end) + proxied:ArrayInsert({ "items" }, "Y") + expect(proxyless:Get { "items" }).is_shallow_equal { "X", "A", "B", "Y" } + expect(proxylessInserted).is("Y") + + proxied:Destroy() + proxyless:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.replication-fidelity.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.replication-fidelity.spec.luau new file mode 100644 index 00000000..7c095d42 --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.replication-fidelity.spec.luau @@ -0,0 +1,1078 @@ +--!strict +--[[ + Proves that tablemanager2's listener output (the public Signals, and + separately the metadata.OriginDiff tree) carries enough deterministic, + self-contained information to reconstruct an independent TableManager's + state via ReplicationHarness. + + The full matrix runs once per feed mode ("signals" and "diff") to prove + each is independently sufficient. + + Out of scope (by design): + - NaN values: NaN ~= NaN breaks both change detection and deep equality, + so NaN is not a replicable value and is intentionally untested. + - ForceNotify beyond the existing scalar-leaf smoke test. + + History: tests marked `FIXME(replication) CONFIRMED` pinned real defects + (see Docs/REPLICATION-FIDELITY-FINDINGS.md). All confirmed defects have + since been fixed, including the diff feed's former batch/Swap "channel + limitation" (resolved by masking tracked arrays out of the batch branch + diff and tagging array operations with metadata.ArrayOp). Both feeds are + now expected to converge on the full matrix. +]] + +return function(t: tiniest) + local ReplicationHarness = require("../Helpers/ReplicationHarness") + local T = require("../../../T") + + local test = t.test + local describe = t.describe + local expect = t.expect + + local feedModes: { ReplicationHarness.FeedMode } = { "signals", "diff" } + + describe("wire boundary", function() + test("wireCopy returns a non-identical deep copy of nested tables", function() + local original = { player = { stats = { health = 100 } } } + local copy = ReplicationHarness.wireCopy(original) + + expect(copy).never_is(original) + expect(copy.player).never_is(original.player) + expect(copy.player.stats).never_is(original.player.stats) + expect(ReplicationHarness.deepEqual(copy, original)).is_true() + end) + + test("scalars pass through wireCopy unchanged", function() + expect(ReplicationHarness.wireCopy(5)).is(5) + expect(ReplicationHarness.wireCopy("hi")).is("hi") + expect(ReplicationHarness.wireCopy(true)).is(true) + expect(ReplicationHarness.wireCopy(nil)).is(nil) + end) + + test("mutating the replica after apply leaves the source untouched", function() + local harness = ReplicationHarness.new { player = { health = 100 } } + harness:Connect() + + harness.Source.Proxy.player.health = 50 + harness.Replica.Proxy.player.health = 999 + + expect(harness.Source:Get { "player", "health" }).is(50) + + harness:Destroy() + end) + end) + + for _, feedMode in feedModes do + describe(`feed: {feedMode}`, function() + describe("scalars", function() + test("set a new root-level key", function() + local harness = ReplicationHarness.new({ player = { health = 100 } }, feedMode) + harness:Connect() + + harness.Source.Proxy.score = 10 + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("change a nested scalar", function() + local harness = ReplicationHarness.new({ player = { stats = { health = 100 } } }, feedMode) + harness:Connect() + + harness.Source.Proxy.player.stats.health = 50 + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("remove a nested scalar key (set to nil)", function() + local harness = ReplicationHarness.new({ player = { health = 100, mana = 5 } }, feedMode) + harness:Connect() + + harness.Source.Proxy.player.mana = nil + + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get({ "player", "mana" }, true)).is(nil) + harness:Destroy() + end) + + test("falsy values are not mistaken for removal", function() + local harness = ReplicationHarness.new({ player = { alive = true, gold = 5 } }, feedMode) + harness:Connect() + + harness.Source.Proxy.player.alive = false + harness.Source.Proxy.player.gold = 0 + + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get { "player", "alive" }).is(false) + expect(harness.Replica:Get { "player", "gold" }).is(0) + harness:Destroy() + end) + + test("rewriting the same value emits nothing", function() + local harness = ReplicationHarness.new({ player = { health = 100 } }, feedMode) + harness:Connect() + + harness.Source.Proxy.player.health = 100 + + expect(harness:OpCount()).is(0) + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + end) + + describe("dictionary keys", function() + test("add a key holding a nested table wholesale", function() + local harness = ReplicationHarness.new({ player = { health = 100 } }, feedMode) + harness:Connect() + + harness.Source.Proxy.settings = { volume = 80, muted = false } + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("remove a key holding a nested table", function() + local harness = ReplicationHarness.new({ + player = { health = 100 }, + settings = { volume = 80, muted = false }, + }, feedMode) + harness:Connect() + + harness.Source.Proxy.settings = nil + + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get({ "settings" }, true)).is(nil) + harness:Destroy() + end) + + test("scalar key overwritten with a table", function() + local harness = ReplicationHarness.new({ value = 5 }, feedMode) + harness:Connect() + + harness.Source.Proxy.value = { nested = true } + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("table key overwritten with a scalar", function() + local harness = ReplicationHarness.new({ value = { nested = true } }, feedMode) + harness:Connect() + + harness.Source.Proxy.value = 5 + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("setting an empty table value yields a table on the replica, not nil", function() + -- nil -> {} emits an "added" leaf node (formerly defect #1: a + -- childless descendantChanged node fired nothing). + local harness = ReplicationHarness.new({ player = { health = 100 } }, feedMode) + harness:Connect() + + harness.Source.Proxy.inventory = {} + + expect(harness:IsConverged()).is_true() + expect(type(harness.Replica:Get({ "inventory" }, true))).is("table") + harness:Destroy() + end) + + test("table replaced with an identical-content different-reference table converges", function() + -- Pins current behavior: whether or not TM emits a ref-change + -- event here, the replica must not diverge. + local harness = ReplicationHarness.new({ player = { health = 100 } }, feedMode) + harness:Connect() + + harness.Source.Proxy.player = { health = 100 } + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("string key that looks numeric stays distinct from the numeric index", function() + local harness = ReplicationHarness.new({ dict = { value = 0 } }, feedMode) + harness:Connect() + + harness.Source.Proxy.dict["1"] = "string-key" + harness.Source.Proxy.dict[1] = "numeric-key" + + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get { "dict", "1" }).is("string-key") + expect(harness.Replica:Get { "dict", 1 }).is("numeric-key") + harness:Destroy() + end) + + test("boolean keys survive the wire", function() + local harness = ReplicationHarness.new({ flags = { placeholder = 0 } }, feedMode) + harness:Connect() + + harness.Source.Proxy.flags[true] = "yes" + harness.Source.Proxy.flags[false] = "no" + + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get { "flags", true }).is("yes") + harness:Destroy() + end) + + test("empty-string key does not collide with the diff sentinel", function() + -- Diff's table<->scalar sentinel is a unique userdata (formerly + -- defect #2: it was "", colliding with genuine "" keys). + local harness = ReplicationHarness.new({ x = 5 }, feedMode) + harness:Connect() + + harness.Source.Proxy.x = { [""] = 7 } + + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get { "x", "" }).is(7) + harness:Destroy() + end) + end) + + describe("subtree replacement", function() + test("replacing an existing table drops keys missing from the new table", function() + local harness = ReplicationHarness.new({ player = { health = 100, mana = 5 } }, feedMode) + harness:Connect() + + harness.Source.Proxy.player = { health = 50 } + + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get({ "player", "mana" }, true)).is(nil) + harness:Destroy() + end) + + test("replacing a deeply nested table drops deep keys", function() + local harness = ReplicationHarness.new({ + a = { b = { c = { keep = 1, drop = 2 } } }, + }, feedMode) + harness:Connect() + + harness.Source.Proxy.a.b = { c = { keep = 1 } } + + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get({ "a", "b", "c", "drop" }, true)).is(nil) + harness:Destroy() + end) + end) + + describe("table<->scalar transitions", function() + -- Regression guards: the teardown events for an old subtree and + -- the parent's scalar event used to arrive in nondeterministic + -- (pairs) order; a consumer that received the scalar first then + -- errored applying the child removals through a non-table value. + -- IsConverged() now fails on ANY recorded apply error, so these + -- assert apply-cleanliness as well as state. + test("deep table overwritten with a scalar applies cleanly", function() + local harness = ReplicationHarness.new({ + config = { + host = "localhost", + ports = { 80, 443 }, + nested = { deep = { x = 1 } }, + }, + }, feedMode) + harness:Connect() + + harness.Source.Proxy.config = "disabled" + + expect(#harness:ApplyErrors()).is(0) + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get { "config" }).is("disabled") + harness:Destroy() + end) + + test("scalar overwritten with a deep table applies cleanly", function() + local harness = ReplicationHarness.new({ config = "disabled" }, feedMode) + harness:Connect() + + harness.Source.Proxy.config = { host = "localhost", nested = { deep = { x = 1 } } } + + expect(#harness:ApplyErrors()).is(0) + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get { "config", "nested", "deep", "x" }).is(1) + harness:Destroy() + end) + + test("table to scalar inside a batch applies cleanly", function() + -- The batch flush runs the same transition diff through + -- CheckForChangesBetween; sentinel-last ordering must hold + -- there too. + local harness = ReplicationHarness.new({ + player = { stats = { str = 5, buffs = { "haste" } }, name = "x" }, + }, feedMode) + harness:Connect() + + harness.Source:Batch(function() + harness.Source.Proxy.player.stats = 0 + harness.Source.Proxy.player.name = "y" + end) + + expect(#harness:ApplyErrors()).is(0) + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get { "player", "stats" }).is(0) + harness:Destroy() + end) + + test("scalar to table inside a batch applies cleanly", function() + local harness = ReplicationHarness.new({ player = { stats = 0 } }, feedMode) + harness:Connect() + + harness.Source:Batch(function() + harness.Source.Proxy.player.stats = { str = 5, dex = { base = 2 } } + end) + + expect(#harness:ApplyErrors()).is(0) + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get { "player", "stats", "dex", "base" }).is(2) + harness:Destroy() + end) + + test("Swap of two deep tables applies cleanly", function() + local harness = ReplicationHarness.new({ + a = { inner = { v = 1, list = { "x" } } }, + b = { other = 2 }, + }, feedMode) + harness:Connect() + + harness.Source:Swap({ "a" }, { "b" }) + + expect(#harness:ApplyErrors()).is(0) + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get { "a", "other" }).is(2) + expect(harness.Replica:Get { "b", "inner", "v" }).is(1) + harness:Destroy() + end) + + test("repeated scalar/table flips at the same key apply cleanly", function() + local harness = ReplicationHarness.new({ slot = { item = "sword" } }, feedMode) + harness:Connect() + + harness.Source.Proxy.slot = 0 + harness.Source.Proxy.slot = { item = "shield", mods = { 1, 2 } } + harness.Source.Proxy.slot = false + + expect(#harness:ApplyErrors()).is(0) + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get { "slot" }).is(false) + harness:Destroy() + end) + + if feedMode == "signals" then + test("teardown events reach the wire before the scalar event", function() + -- Order assertion at the op-log level: a consumer applying + -- in receipt order must see every removal UNDER the key + -- before the scalar write AT the key, or it would apply + -- them through a non-table value. + local harness = ReplicationHarness.new({ + config = { host = "localhost", ports = { 80, 443 } }, + }, "signals") + harness:Connect() + + harness.Source.Proxy.config = "off" + + local sawScalarEvent = false + for i, entry in (harness :: any)._opLog do + if entry.Signal ~= "ValueChanged" or entry.Path[1] ~= "config" then + continue + end + if #entry.Path == 1 then + sawScalarEvent = true + else + expect(sawScalarEvent) + .with_context(`teardown op #{i} arrived after the scalar event`) + .never_is_true() + end + end + expect(sawScalarEvent).is_true() + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + end + end) + + describe("arrays of scalars", function() + test("append", function() + local harness = ReplicationHarness.new({ items = { "a", "b" } }, feedMode) + harness:Connect() + + harness.Source:ArrayInsert("items", "c") + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("insert at index shifts later elements", function() + local harness = ReplicationHarness.new({ items = { "a", "c" } }, feedMode) + harness:Connect() + + harness.Source:ArrayInsert("items", 2, "b") + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("remove shifts later elements", function() + local harness = ReplicationHarness.new({ items = { "a", "b", "c" } }, feedMode) + harness:Connect() + + harness.Source:ArrayRemove("items", 1) + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("overwrite an existing index via the proxy", function() + local harness = ReplicationHarness.new({ items = { "a", "b", "c" } }, feedMode) + harness:Connect() + + harness.Source.Proxy.items[2] = "B" + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("ArraySwapRemove", function() + local harness = ReplicationHarness.new({ items = { "a", "b", "c", "d" } }, feedMode) + harness:Connect() + + harness.Source:ArraySwapRemove("items", 1) + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("append via direct proxy index write (one past the end)", function() + local harness = ReplicationHarness.new({ items = { "a", "b" } }, feedMode) + harness:Connect() + + harness.Source.Proxy.items[3] = "c" + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("delete the tail element via proxy nil write", function() + local harness = ReplicationHarness.new({ items = { "a", "b" } }, feedMode) + harness:Connect() + + harness.Source.Proxy.items[2] = nil + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("nil write in the middle of an array (hole) replicates the hole", function() + -- A plain nil write is NOT an array op: it arrives without + -- metadata.ArrayOp and must be applied as a non-shifting Set, + -- which preserves the hole on both feeds. + local harness = ReplicationHarness.new({ items = { "a", "b", "c" } }, feedMode) + harness:Connect() + + harness.Source.Proxy.items[2] = nil + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("wholesale replacement that shrinks the array by more than one", function() + -- A wholesale replacement arrives as one diff delivery whose + -- numeric removals are applied as non-shifting nil Sets (no + -- metadata.ArrayOp), so apply order doesn't matter and the + -- trailing nils truncate the replica's array. + local harness = ReplicationHarness.new({ items = { "a", "b", "c", "d" } }, feedMode) + harness:Connect() + + harness.Source.Proxy.items = { "a" } + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("nested array path (not root-level)", function() + local harness = ReplicationHarness.new({ + player = { inventory = { items = { "sword" } } }, + }, feedMode) + harness:Connect() + + harness.Source:ArrayInsert({ "player", "inventory", "items" }, "shield") + harness.Source:ArrayRemove({ "player", "inventory", "items" }, 1) + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + end) + + describe("arrays of tables", function() + test("mutate a field on an array element", function() + local harness = ReplicationHarness.new({ + items = { { hp = 1 }, { hp = 2 } }, + }, feedMode) + harness:Connect() + + harness.Source.Proxy.items[1].hp = 99 + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("insert a table element, shifting later elements", function() + local harness = ReplicationHarness.new({ + items = { { hp = 1 }, { hp = 3 } }, + }, feedMode) + harness:Connect() + + harness.Source:ArrayInsert("items", 2, { hp = 2 }) + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + end) + + describe("array helper methods", function() + test("ArrayRemoveFirstValue removes the first match", function() + local harness = ReplicationHarness.new({ items = { "a", "b", "a" } }, feedMode) + harness:Connect() + + local index = harness.Source:ArrayRemoveFirstValue("items", "a") + + expect(index).is(1) + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("ArrayRemoveFirstValue with no match emits nothing", function() + local harness = ReplicationHarness.new({ items = { "a", "b" } }, feedMode) + harness:Connect() + + local index = harness.Source:ArrayRemoveFirstValue("items", "zzz") + + expect(index).is(nil) + expect(harness:OpCount()).is(0) + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("ArraySwapRemoveFirstValue removes the first match", function() + local harness = ReplicationHarness.new({ items = { "a", "b", "c", "d" } }, feedMode) + harness:Connect() + + local index = harness.Source:ArraySwapRemoveFirstValue("items", "b") + + expect(index).is(2) + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("ArraySwapRemoveFirstValue with no match emits nothing", function() + local harness = ReplicationHarness.new({ items = { "a", "b" } }, feedMode) + harness:Connect() + + local index = harness.Source:ArraySwapRemoveFirstValue("items", "zzz") + + expect(index).is(nil) + expect(harness:OpCount()).is(0) + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + end) + + describe("structural methods", function() + -- Swap/MoveTo run an internal Batch; their flush deliveries are + -- masked + ArrayOp-tagged like any other batch (see findings doc). + test("MoveTo relocates a table between branches", function() + local harness = ReplicationHarness.new({ + player = { stats = { str = 5, dex = 3 } }, + archive = { placeholder = 0 }, + }, feedMode) + harness:Connect() + + harness.Source:MoveTo({ "player", "stats" }, { "archive", "stats" }) + + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get({ "player", "stats" }, true)).is(nil) + expect(harness.Replica:Get { "archive", "stats", "str" }).is(5) + harness:Destroy() + end) + + test("CopyTo deep-copies; later source mutations do not leak into the copy", function() + local harness = ReplicationHarness.new({ + template = { cost = 5 }, + slots = { placeholder = 0 }, + }, feedMode) + harness:Connect() + + harness.Source:CopyTo({ "template" }, { "slots", "copy" }) + expect(harness:IsConverged()).is_true() + + harness.Source.Proxy.template.cost = 9 + + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get { "slots", "copy", "cost" }).is(5) + expect(harness.Replica:Get { "template", "cost" }).is(9) + harness:Destroy() + end) + + test("Swap exchanges two tables in different branches", function() + local harness = ReplicationHarness.new({ + a = { v = 1 }, + b = { v = 2 }, + }, feedMode) + harness:Connect() + + harness.Source:Swap({ "a" }, { "b" }) + + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get { "a", "v" }).is(2) + expect(harness.Replica:Get { "b", "v" }).is(1) + harness:Destroy() + end) + + test("Swap exchanges a scalar with a table", function() + local harness = ReplicationHarness.new({ + a = 5, + b = { v = 2 }, + }, feedMode) + harness:Connect() + + harness.Source:Swap({ "a" }, { "b" }) + + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get { "b" }).is(5) + expect(harness.Replica:Get { "a", "v" }).is(2) + harness:Destroy() + end) + + test("Swap exchanges two elements of the same array", function() + local harness = ReplicationHarness.new({ items = { "a", "b", "c" } }, feedMode) + harness:Connect() + + harness.Source:Swap({ "items", 1 }, { "items", 3 }) + + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get { "items", 1 }).is("c") + expect(harness.Replica:Get { "items", 3 }).is("a") + harness:Destroy() + end) + + test("Set with buildTablesDynamically creates intermediate tables", function() + -- The missing path is built as ONE subtree assigned through the + -- proxy in a single write (formerly: level-by-level raw writes + -- that bypassed change detection entirely). + local harness = ReplicationHarness.new({ existing = 1 }, feedMode) + harness:Connect() + + harness.Source:Set({ "a", "b", "c" }, 5, true) + + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get { "a", "b", "c" }).is(5) + harness:Destroy() + end) + end) + + describe("batch", function() + -- Batch flush contract: the non-array flush masks tracked-array + -- subtrees out of each branch diff, so array changes reach + -- consumers exactly once — via the array flush's ArrayOp-tagged + -- per-op deliveries. Both feeds converge on every case below. + test("multiple mixed operations in one Batch converge", function() + local harness = ReplicationHarness.new({ + player = { health = 100, mana = 5 }, + items = { "a", "b" }, + }, feedMode) + harness:Connect() + + harness.Source:Batch(function() + harness.Source.Proxy.player.health = 50 + harness.Source.Proxy.player.mana = nil + harness.Source.Proxy.settings = { volume = 80 } + harness.Source:ArrayInsert("items", "c") + harness.Source:ArrayRemove("items", 1) + end) + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("array created inside the batch then inserted into", function() + -- Arrays created during a batch are pruned from TrackedPaths at + -- flush, so their creation flows ONLY through the non-array + -- flush as ordinary adds (formerly defect #4: both phases fired). + local harness = ReplicationHarness.new({ existing = 1 }, feedMode) + harness:Connect() + + harness.Source:Batch(function() + harness.Source.Proxy.newItems = {} + harness.Source:ArrayInsert("newItems", "a") + harness.Source:ArrayInsert("newItems", "b") + end) + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("ArrayInsert of a table element inside a Batch", function() + -- The tracked array (including its elements' string-keyed + -- fields) is masked out of the non-array flush; only the array + -- flush's ArrayInserted reaches consumers. + local harness = ReplicationHarness.new({ items = { { hp = 1 } } }, feedMode) + harness:Connect() + + harness.Source:Batch(function() + harness.Source:ArrayInsert("items", 1, { hp = 7 }) + end) + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("direct index write in a batch routes through LCS and converges", function() + local harness = ReplicationHarness.new({ items = { "a", "b", "c" } }, feedMode) + harness:Connect() + + harness.Source:Batch(function() + harness.Source.Proxy.items[2] = "X" + harness.Source:ArrayInsert("items", "d") + harness.Source:ArrayRemove("items", 1) + end) + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("whole array replaced inside a batch then mutated", function() + -- A mid-batch reference replacement is captured by the pre-batch + -- snapshot, so the {a,b}->{x,y} transition is emitted in full via + -- LCS (formerly defect #5: only the trailing insert emitted). + local harness = ReplicationHarness.new({ items = { "a", "b" } }, feedMode) + harness:Connect() + + harness.Source:Batch(function() + harness.Source.Proxy.items = { "x" } + harness.Source:ArrayInsert("items", "y") + end) + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("array removed entirely inside a batch after being mutated", function() + local harness = ReplicationHarness.new({ items = { "a" } }, feedMode) + harness:Connect() + + harness.Source:Batch(function() + harness.Source:ArrayInsert("items", "b") + harness.Source.Proxy.items = nil + end) + + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get({ "items" }, true)).is(nil) + harness:Destroy() + end) + + test("pure move (remove + reinsert same value) reaches the wire as a remove/insert pair", function() + local harness = ReplicationHarness.new({ items = { "a", "b", "c" } }, feedMode) + harness:Connect() + + harness.Source:Batch(function() + harness.Source:ArrayRemove("items", 1) + harness.Source:ArrayInsert("items", 3, "a") + end) + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("ArraySwapRemove inside a batch", function() + -- LCS sees Set@index then Remove@lastIndex, mirroring the actual + -- mutation and the non-batched emission order. + local harness = ReplicationHarness.new({ items = { "a", "b", "c", "d" } }, feedMode) + harness:Connect() + + harness.Source:Batch(function() + harness.Source:ArraySwapRemove("items", 1) + end) + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("two arrays and a scalar branch mutated in one batch", function() + local harness = ReplicationHarness.new({ + player = { inventory = { items = { "a" } } }, + scores = { 1, 2 }, + name = "x", + }, feedMode) + harness:Connect() + + harness.Source:Batch(function() + harness.Source:ArrayInsert({ "player", "inventory", "items" }, "b") + harness.Source:ArrayRemove("scores", 1) + harness.Source.Proxy.name = "y" + end) + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("net-zero batch emits nothing", function() + local harness = ReplicationHarness.new({ items = { "a" } }, feedMode) + harness:Connect() + + harness.Source:Batch(function() + harness.Source:ArrayInsert("items", "b") + harness.Source:ArrayRemove("items", 2) + end) + + expect(harness:OpCount()).is(0) + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("element field mutation plus a shift of the same array in one batch", function() + -- An in-place element interior write is captured by the pre-batch + -- snapshot, so LCS re-emits the changed element as a whole-value + -- op at its post-shift index; the non-array flush is masked out. + local harness = ReplicationHarness.new({ items = { { hp = 1 } } }, feedMode) + harness:Connect() + + harness.Source:Batch(function() + harness.Source.Proxy.items[1].hp = 99 + harness.Source:ArrayInsert("items", 1, { id = "new" }) + end) + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + end) + + describe("ForceNotify", function() + test("does not corrupt the replica", function() + local harness = ReplicationHarness.new({ player = { health = 100 } }, feedMode) + harness:Connect() + + harness.Source.Proxy.player.health = 50 + harness.Source:ForceNotify("player.health") + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + end) + + describe("ambiguity", function() + --[[ + Shifting array ops are distinguished from plain numeric key + writes by metadata.ArrayOp, so the diff feed no longer guesses: + an added-at-numeric-leaf entry WITHOUT ArrayOp is always a + non-shifting Set. These tests pin that boundary/sparse numeric + dictionary writes converge under that rule. + ]] + test("sparse numeric dictionary keys converge", function() + local harness = ReplicationHarness.new({ lookup = { [1] = "a", [3] = "c" } }, feedMode) + harness:Connect() + + harness.Source.Proxy.lookup[2] = "b" + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + + test("boundary numeric key add on an array-like table converges", function() + local harness = ReplicationHarness.new({ lookup = { [1] = "a" } }, feedMode) + harness:Connect() + + harness.Source.Proxy.lookup[2] = "b" + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + end) + + describe("schema validation", function() + test("rejected writes emit nothing and leave the replica untouched", function() + local harness = ReplicationHarness.new({ player = { health = 100 }, items = { "a" } }, feedMode, { + Schema = T.interface { + player = T.interface { health = T.number }, + items = T.array(T.string), + }, + OnValidationFailed = function() end, + }) + harness:Connect() + + harness.Source.Proxy.player.health = "not-a-number" + harness.Source:ArrayInsert("items", 12345) + + expect(harness:OpCount()).is(0) + expect(harness:IsConverged()).is_true() + expect(harness.Replica:Get { "player", "health" }).is(100) + harness:Destroy() + end) + end) + + describe("late join", function() + test("replica seeded from a mid-stream snapshot stays converged", function() + local harness = ReplicationHarness.new({ player = { health = 100 }, items = { "a" } }, feedMode) + + -- Mutations BEFORE the replica connects (a client not yet joined). + harness.Source.Proxy.player.health = 75 + harness.Source:ArrayInsert("items", "b") + harness.Source.Proxy.session = { id = "abc" } + + -- Join: snapshot, then stream. + harness:ResyncReplica() + harness:Connect() + + harness.Source.Proxy.player.health = 50 + harness.Source:ArrayRemove("items", 1) + harness.Source.Proxy.session = nil + + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + end) + + describe("re-entrant mutation", function() + test("a source listener that clamps during notification", function() + -- Re-entrant writes are queued by ChangeDetector and dispatched + -- AFTER the outer write's events, so consumers applying in + -- receipt order see -50 then the clamp to 0 on both feeds. + local harness = ReplicationHarness.new({ player = { health = 100 } }, feedMode) + harness:Connect() + + harness.Source:OnValueChange("player.health", function(newValue) + if type(newValue) == "number" and newValue < 0 then + harness.Source:Set({ "player", "health" }, 0) + end + end) + + harness.Source.Proxy.player.health = -50 + + expect(harness.Source:Get { "player", "health" }).is(0) + expect(harness:IsConverged()).is_true() + harness:Destroy() + end) + end) + end) + end + + describe("echo order (signals feed)", function() + --[[ + The replica must not only converge - it must RE-EMIT an equivalent + event stream in the same order, since the rebuilt TableReplicator's + client-side listeners hang off the replica's own signals. + EchoMatches compares normalized state-bearing ops (see harness notes; + a replica can only re-emit ArraySet as ValueChanged via :Set). + ]] + local function echoTest(name: string, initialData: any, mutate: (harness: any) -> ()) + test(name, function() + local harness = ReplicationHarness.new(initialData, "signals") + harness:Connect() + + mutate(harness) + + expect(harness:IsConverged()).is_true() + expect(harness:EchoMatches()).is_true() + harness:Destroy() + end) + end + + echoTest("scalar set", { player = { health = 100 } }, function(harness) + harness.Source.Proxy.player.health = 50 + end) + + echoTest("nested key removal", { player = { health = 100, mana = 5 } }, function(harness) + harness.Source.Proxy.player.mana = nil + end) + + echoTest("subtree replacement", { player = { health = 100, mana = 5 } }, function(harness) + harness.Source.Proxy.player = { health = 50, stamina = 10 } + end) + + echoTest("array insert in the middle", { items = { "a", "c" } }, function(harness) + harness.Source:ArrayInsert("items", 2, "b") + end) + + echoTest("array remove", { items = { "a", "b", "c" } }, function(harness) + harness.Source:ArrayRemove("items", 2) + end) + + echoTest("ArraySwapRemove", { items = { "a", "b", "c", "d" } }, function(harness) + harness.Source:ArraySwapRemove("items", 1) + end) + + echoTest("mixed batch", { + player = { health = 100, mana = 5 }, + items = { "a", "b" }, + }, function(harness) + harness.Source:Batch(function() + harness.Source.Proxy.player.health = 50 + harness.Source.Proxy.player.mana = nil + harness.Source:ArrayInsert("items", "c") + harness.Source:ArrayRemove("items", 1) + end) + end) + end) + + describe("deferred listeners (smoke)", function() + test("a deferred-mode source still converges after the scheduler drains", function() + local harness = ReplicationHarness.new({ player = { health = 100 }, items = { "a" } }, "signals", { + ListenerFireMode = "Deferred", + }) + harness:Connect() + + harness.Source.Proxy.player.health = 50 + harness.Source:ArrayInsert("items", "b") + + task.wait() + + expect(harness:IsConverged()).is_true() + expect(harness:EchoMatches()).is_true() + harness:Destroy() + end) + end) + + describe("per-step convergence", function() + test("every individual operation in a long mixed sequence converges", function() + for _, feedMode in feedModes do + local harness = ReplicationHarness.new({ + player = { health = 100, mana = 5 }, + items = { "a", "b" }, + }, feedMode) + harness:Connect() + + local steps: { () -> () } = { + function() + harness.Source.Proxy.player.health = 50 + end, + function() + harness.Source.Proxy.player.buffs = { "haste" } + end, + function() + harness.Source:ArrayInsert("items", 1, "z") + end, + function() + harness.Source:ArraySwapRemove("items", 1) + end, + function() + harness.Source.Proxy.player = { health = 1 } + end, + function() + harness.Source:Batch(function() + harness.Source.Proxy.player.health = 2 + harness.Source:ArrayInsert("items", "w") + end) + end, + function() + harness.Source.Proxy.items = nil + end, + } + + for i, step in steps do + expect(harness:Step(step)).with_context(`feed={feedMode} step={i}`).is_true() + end + + harness:Destroy() + end + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/TM/TableManager.value-listener-methods.spec.luau b/lib/tablemanager2/src/Tests/TM/TableManager.value-listener-methods.spec.luau new file mode 100644 index 00000000..0bccafaf --- /dev/null +++ b/lib/tablemanager2/src/Tests/TM/TableManager.value-listener-methods.spec.luau @@ -0,0 +1,626 @@ +--!strict + +return function(t: tiniest) + local TableManager = require("../../TableManager") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("Method: OnChange", function() + test("fires for direct leaf change with Diff metadata", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local fireCount = 0 + local hasDiff = false + local originPath = nil + + local conn = manager:OnChange({ "player", "health" }, function(_newValue, _oldValue, metadata) + fireCount += 1 + hasDiff = metadata.Diff ~= nil + originPath = metadata.OriginPath + end) + + manager.Proxy.player.health = 50 + + expect(fireCount).is(1) + expect(hasDiff).is_true() + expect(originPath).is_shallow_equal { "player", "health" } + + conn:Disconnect() + manager:Destroy() + end) + + test("fires for ancestor notification with Diff=nil", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local fireCount = 0 + local diffWasNil = false + + manager:OnChange({ "player" }, function(_newValue, _oldValue, metadata) + fireCount += 1 + diffWasNil = (metadata.Diff == nil) + end) + + manager.Proxy.player.health = 50 + + expect(fireCount).is(1) + expect(diffWasNil).is_true() + + manager:Destroy() + end) + + test("supports dot-string path input", function() + local manager = TableManager.new { + player = { stats = { health = 100 } }, + } + local managerAny: any = manager + + local fireCount = 0 + managerAny:OnChange("player.stats.health", function(newValue, oldValue, metadata) + fireCount += 1 + expect(newValue).is(75) + expect(oldValue).is(100) + expect(metadata.Diff).exists() + end) + + manager.Proxy.player.stats.health = 75 + + expect(fireCount).is(1) + manager:Destroy() + end) + + test("fires when a descendant changes", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local fireCount = 0 + manager:OnChange({ "player" }, function() + fireCount += 1 + end) + + manager.Proxy.player.health = 50 + + expect(fireCount).is(1) + manager:Destroy() + end) + + test("Once option auto-disconnects after first invocation", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local fireCount = 0 + local conn = manager:OnChange({ "player", "health" }, function() + fireCount += 1 + end, { Once = true }) + + manager.Proxy.player.health = 50 + manager.Proxy.player.health = 25 + + expect(fireCount).is(1) + expect(conn.Connected).never_is_true() + + manager:Destroy() + end) + + test("pairs with ValueChanged signal exactly once per leaf change", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local listenerCount = 0 + local signalCount = 0 + local signalPath = nil + + manager:OnChange({ "player", "health" }, function() + listenerCount += 1 + end) + + local conn = manager.ValueChanged:Connect(function(path, newValue, oldValue) + signalCount += 1 + signalPath = path + expect(newValue).is(50) + expect(oldValue).is(100) + end) + + manager.Proxy.player.health = 50 + + expect(listenerCount).is(1) + expect(signalCount).is(1) + expect(signalPath).is_shallow_equal { "player", "health" } + + conn:Disconnect() + manager:Destroy() + end) + + test("provides full metadata for direct changes", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local capturedMetadata = nil + local conn = manager:OnChange({ "player", "health" }, function(_newValue, _oldValue, metadata) + capturedMetadata = metadata + end) + + manager.Proxy.player.health = 50 + + expect(capturedMetadata).exists() + assert(capturedMetadata, "Expected metadata to be provided for direct change") + expect(capturedMetadata.Diff).exists() + expect(capturedMetadata.OriginPath).is_shallow_equal { "player", "health" } + expect(capturedMetadata.OriginDiff).exists() + + conn:Disconnect() + manager:Destroy() + end) + + test("uses Diff=nil plus OriginDiff for ancestor notifications", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local ancestorMetadata = nil + local conn = manager:OnChange({ "player" }, function(_newValue, _oldValue, metadata) + if metadata.Diff == nil then + ancestorMetadata = metadata + end + end) + + manager.Proxy.player.health = 50 + + expect(ancestorMetadata).exists() + assert(ancestorMetadata, "Expected metadata to be provided for ancestor notification") + expect(ancestorMetadata.Diff).is(nil) + expect(ancestorMetadata.OriginPath).is_shallow_equal { "player", "health" } + expect(ancestorMetadata.OriginDiff).exists() + + conn:Disconnect() + manager:Destroy() + end) + + test("leaf listener has Diff equal to OriginDiff", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local capturedMetadata = nil + local conn = manager:OnChange({ "player", "health" }, function(_newValue, _oldValue, metadata) + capturedMetadata = metadata + end) + + manager.Proxy.player.health = 50 + + expect(capturedMetadata).exists() + assert(capturedMetadata, "Expected metadata to be provided for direct change") + expect(capturedMetadata.Diff).is(capturedMetadata.OriginDiff) + + conn:Disconnect() + manager:Destroy() + end) + + test("supports root path listeners", function() + local manager = TableManager.new { + player = { health = 100 }, + settings = nil :: any, + } + + local fireCount = 0 + local conn = manager:OnChange({}, function(_newValue, _oldValue, metadata) + fireCount += 1 + expect(metadata.OriginPath).is_shallow_equal { "settings" } + end) + + manager.Proxy.settings = { volume = 80 } + + expect(fireCount).is(1) + + conn:Disconnect() + manager:Destroy() + end) + + test("supports multiple listeners on the same path", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local count1 = 0 + local count2 = 0 + + local conn1 = manager:OnChange({ "player", "health" }, function() + count1 += 1 + end) + local conn2 = manager:OnChange({ "player", "health" }, function() + count2 += 1 + end) + + manager.Proxy.player.health = 50 + + expect(count1).is(1) + expect(count2).is(1) + + conn1:Disconnect() + conn2:Disconnect() + manager:Destroy() + end) + + test("disconnect prevents later listener invocation", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local fired = false + local conn = manager:OnChange({ "player", "health" }, function() + fired = true + end) + + conn:Disconnect() + manager.Proxy.player.health = 50 + + expect(fired).never_is_true() + manager:Destroy() + end) + end) + + describe("Method: OnValueChange", function() + test("fires only for direct reassignment of the exact path", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local fireCount = 0 + local conn = manager:OnValueChange({ "player", "health" }, function() + fireCount += 1 + end) + + manager.Proxy.player.health = 50 + + expect(fireCount).is(1) + + conn:Disconnect() + manager:Destroy() + end) + + test("does NOT fire when only a descendant changes", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local fireCount = 0 + local conn = manager:OnValueChange({ "player" }, function() + fireCount += 1 + end) + + manager.Proxy.player.health = 50 + + expect(fireCount).is(0) + + conn:Disconnect() + manager:Destroy() + end) + + test("fires when the whole subtree is directly reassigned", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local fireCount = 0 + local conn = manager:OnValueChange({ "player" }, function() + fireCount += 1 + end) + + manager.Proxy.player = { health = 50 } + + expect(fireCount).is(1) + + conn:Disconnect() + manager:Destroy() + end) + end) + + describe("Method: Observe", function() + test("immediately fires with the current value and nil metadata", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local capturedValue = nil + local capturedOld: any = "unset" + local capturedMetadata: any = "unset" + + local conn = manager:Observe({ "player", "health" }, function(newValue, oldValue, metadata) + capturedValue = newValue + capturedOld = oldValue + capturedMetadata = metadata + end) + + expect(capturedValue).is(100) + expect(capturedOld).is(nil) + expect(capturedMetadata).is(nil) + + conn:Disconnect() + manager:Destroy() + end) + + test("fires again with real metadata on subsequent changes", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local fireCount = 0 + local lastValue = nil + local lastMetadata: any = nil + + local conn = manager:Observe({ "player", "health" }, function(newValue, _oldValue, metadata) + fireCount += 1 + lastValue = newValue + lastMetadata = metadata + end) + + manager.Proxy.player.health = 50 + + expect(fireCount).is(2) -- initial + change + expect(lastValue).is(50) + expect(lastMetadata).exists() + + conn:Disconnect() + manager:Destroy() + end) + + test("immediately fires with nil for a path that does not exist yet", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local capturedValue: any = "unset" + local conn = manager:Observe({ "player", "mana" }, function(newValue) + capturedValue = newValue + end) + + expect(capturedValue).is(nil) + + conn:Disconnect() + manager:Destroy() + end) + + test("disconnect prevents later listener invocation", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local fireCount = 0 + local conn = manager:Observe({ "player", "health" }, function() + fireCount += 1 + end) + + conn:Disconnect() + manager.Proxy.player.health = 50 + + expect(fireCount).is(1) -- only the initial fire + manager:Destroy() + end) + end) + + describe("Wildcard listeners", function() + test("OnValueChange with a wildcard path fires for any matching key", function() + local manager = TableManager.new { + Players = { + p123 = { Health = 100 }, + p456 = { Health = 80 }, + }, + } + + local fired: { [string]: number } = {} + local conn = manager:OnValueChange({ "Players", "*", "Health" }, function(newValue, _oldValue, metadata) + local playerId = metadata.WildcardMatches and metadata.WildcardMatches[1] + fired[playerId] = newValue + end) + + manager.Proxy.Players.p123.Health = 90 + manager.Proxy.Players.p456.Health = 70 + + expect(fired.p123).is(90) + expect(fired.p456).is(70) + + conn:Disconnect() + manager:Destroy() + end) + + test("a new key added under a wildcard-watched parent fires the wildcard ancestor listener", function() + local manager = TableManager.new { + Players = { + p123 = { Health = 100 }, + }, + } + + local matchedIds: { string } = {} + local conn = manager:OnChange({ "Players", "*" }, function(_newValue, _oldValue, metadata) + if metadata.WildcardMatches then + table.insert(matchedIds, metadata.WildcardMatches[1]) + end + end) + + manager.Proxy.Players.p456 = { Health = 80 } + + expect(#matchedIds).is(1) + expect(matchedIds[1]).is("p456") + + conn:Disconnect() + manager:Destroy() + end) + + test("supports dot-string wildcard path input", function() + local manager = TableManager.new { + Players = { + p123 = { Health = 100 }, + }, + } + local managerAny: any = manager + + local fireCount = 0 + local conn = managerAny:OnValueChange("Players.*.Health", function() + fireCount += 1 + end) + + manager.Proxy.Players.p123.Health = 50 + + expect(fireCount).is(1) + + conn:Disconnect() + manager:Destroy() + end) + end) + + describe("Method: OnKeyChange", function() + test("fires when a scalar key changes", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local fireCount = 0 + local seenKey = nil + local seenNew = nil + local seenOld = nil + + manager:OnKeyChange("player", function(key, newValue, oldValue) + fireCount += 1 + seenKey = key + seenNew = newValue + seenOld = oldValue + end) + + manager.Proxy.player.health = 50 + + expect(fireCount).is(1) + expect(seenKey).is("health") + expect(seenNew).is(50) + expect(seenOld).is(100) + + manager:Destroy() + end) + + test("fires for array mutations at ancestor key", function() + local manager = TableManager.new { + game = { + players = { "Alice", "Bob" }, + }, + } + + local fireCount = 0 + + manager:OnKeyChange({ "game" }, function(key, _newValue, _oldValue) + fireCount += 1 + expect(key).is("players") + end) + + manager:ArrayInsert("game.players", "Charlie") + + expect(fireCount).is(1) + manager:Destroy() + end) + + test("supports dot-string path input", function() + local manager = TableManager.new { + game = { state = { level = 1 } }, + } + local managerAny: any = manager + + local fireCount = 0 + managerAny:OnKeyChange("game.state", function(key, newValue, oldValue) + fireCount += 1 + expect(key).is("level") + expect(newValue).is(2) + expect(oldValue).is(1) + end) + + manager.Proxy.game.state.level = 2 + + expect(fireCount).is(1) + manager:Destroy() + end) + + test("respects ListenDepth=0 for descendant changes", function() + local manager = TableManager.new { + game = { state = { level = 1 } }, + } + + local fireCount = 0 + manager:OnKeyChange({ "game" }, function() + fireCount += 1 + end, { ListenDepth = 0 }) + + manager.Proxy.game.state.level = 2 + + expect(fireCount).is(0) + manager:Destroy() + end) + + test("pairs with KeyChanged signal payload", function() + local manager = TableManager.new { + player = { health = 100 }, + } + + local listenerCount = 0 + local signalCount = 0 + + manager:OnKeyChange({ "player" }, function() + listenerCount += 1 + end) + + local conn = manager.KeyChanged:Connect(function(path, key, newValue, oldValue) + signalCount += 1 + expect(path).is_shallow_equal { "player" } + expect(key).is("health") + expect(newValue).is(80) + expect(oldValue).is(100) + end) + + manager.Proxy.player.health = 80 + + expect(listenerCount).is(1) + expect(signalCount).is(1) + + conn:Disconnect() + manager:Destroy() + end) + end) + + describe("Config: SignalFireMode", function() + test("Coalesced collapses repeated Sets to the same path into one ValueChanged fire", function() + local manager = TableManager.new({ + player = { health = 100 }, + }, { + SignalFireMode = "Coalesced", + }) + + local fireCount = 0 + local lastNewValue = nil + local lastOldValue = nil + + manager.ValueChanged:Connect(function(_path, newValue, oldValue) + fireCount += 1 + lastNewValue = newValue + lastOldValue = oldValue + end) + + manager:Set({ "player", "health" }, 75) + manager:Set({ "player", "health" }, 50) + + expect(fireCount).is(0) -- nothing has run synchronously yet + + task.wait() + + expect(fireCount).is(1) + expect(lastNewValue).is(50) + expect(lastOldValue).is(100) + + manager:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/coverage.spec.luau b/lib/tablemanager2/src/Tests/coverage.spec.luau new file mode 100644 index 00000000..3e186a67 --- /dev/null +++ b/lib/tablemanager2/src/Tests/coverage.spec.luau @@ -0,0 +1,238 @@ +--!strict +--[[ + Tests for the observation gate (Phase 1 of the coverage-tree plan): + - ListenerRegistry:CoversChangesAt, the O(depth) conservative coverage check + built on the unified tree's Count/Subtree counters. + - Mutator's shouldTrackChangesAt gate, which skips the snapshot/diff cost for + writes nobody could observe (no covering listener, no connected global + Signal, no active link group). +]] + +return function(t: tiniest) + local TableManager = require("../TableManager") + local ListenerRegistry = require("../ListenerRegistry") + + local test = t.test + local describe = t.describe + local expect = t.expect + + describe("ListenerRegistry:CoversChangesAt", function() + test("returns false when nothing is registered", function() + local registry = ListenerRegistry.new() + + expect(registry:CoversChangesAt({ "a" })).never_is_true() + + registry:Destroy() + end) + + test("root-only listener covers a deep change", function() + local registry = ListenerRegistry.new() + registry:RegisterListener("ValueChanged", {}, function() end) + + expect(registry:CoversChangesAt({ "a", "b", "c" })).is_true() + + registry:Destroy() + end) + + test("sibling-only listener does not cover an unrelated path", function() + local registry = ListenerRegistry.new() + registry:RegisterListener("ValueChanged", { "a", "x" }, function() end) + + expect(registry:CoversChangesAt({ "a", "y" })).never_is_true() + + registry:Destroy() + end) + + test("descendant-only listener forces an ancestor write to be tracked", function() + local registry = ListenerRegistry.new() + registry:RegisterListener("ValueChanged", { "a", "b", "c" }, function() end) + + -- A write at "a" could replace/restructure the subtree containing + -- the descendant listener's path, so the ancestor must be covered. + expect(registry:CoversChangesAt({ "a" })).is_true() + -- The exact descendant path is, of course, also covered. + expect(registry:CoversChangesAt({ "a", "b", "c" })).is_true() + -- A disjoint sibling of the ancestor is still uncovered. + expect(registry:CoversChangesAt({ "z" })).never_is_true() + + registry:Destroy() + end) + + test("disconnecting the only covering listener drops coverage", function() + local registry = ListenerRegistry.new() + local connection = registry:RegisterListener("ValueChanged", { "x", "y" }, function() end) + + expect(registry:CoversChangesAt({ "x", "y" })).is_true() + + connection:Disconnect() + + expect(registry:CoversChangesAt({ "x", "y" })).never_is_true() + + registry:Destroy() + end) + + test("a wildcard listener forces full coverage everywhere", function() + local registry = ListenerRegistry.new() + registry:RegisterListener("ValueChanged", { "players", "*", "hp" }, function() end) + + expect(registry:CoversChangesAt({ "totally", "unrelated", "path" })).is_true() + + registry:Destroy() + end) + end) + + describe("Observation gate integration (TableManager)", function() + test("an unobserved write still updates live state", function() + local manager = TableManager.new { + a = { b = 1 }, + z = 1, + } + + manager:Set("z", 2) + expect(manager:Get("z")).is(2) + + -- Late registration after the unobserved write still works correctly + -- going forward; skipping the diff never corrupts later writes. + local newValue + manager:OnValueChange("z", function(value) + newValue = value + end) + manager:Set("z", 3) + expect(newValue).is(3) + expect(manager:Get("z")).is(3) + + manager:Destroy() + end) + + test("root listener still observes a deep, otherwise-unobserved write", function() + local manager = TableManager.new { + player = { stats = { health = 100 } }, + } + + local fireCount = 0 + manager:OnChange({}, function() + fireCount += 1 + end) + + manager:Set("player.stats.health", 50) + + expect(fireCount).is(1) + expect(manager:Get("player.stats.health")).is(50) + + manager:Destroy() + end) + + test("sibling-only listener does not fire for an unrelated write", function() + local manager = TableManager.new { + a = { x = 1 }, + b = { y = 1 }, + } + + local fireCount = 0 + manager:OnValueChange("a.x", function() + fireCount += 1 + end) + + manager:Set("b.y", 2) + + expect(fireCount).is(0) + expect(manager:Get("b.y")).is(2) + + manager:Destroy() + end) + + test("a connected global Signal forces tracking even with no registry listener", function() + local manager = TableManager.new { + a = { x = 1 }, + } + + local fired = false + manager.ValueChanged:Connect(function() + fired = true + end) + + manager:Set("a.x", 2) + + expect(fired).is_true() + expect(manager:Get("a.x")).is(2) + + manager:Destroy() + end) + + test("an active link group fans out a write with no direct listener on the source", function() + local shared = { health = 100 } + local a = TableManager.new(shared) + local b = TableManager.new(shared) + + TableManager.Link({ { a }, { b } }) + + -- `a` has no direct OnChange/OnValueChange listener and no connected + -- global Signal: without the link-group bypass, `a`'s write would be + -- gated out, its OnValueChanged dispatch (which FanOutSet rides + -- inside of) would never run, and `b`'s listener would never fire. + local bNew, bOld + b:OnValueChange("health", function(newValue, oldValue) + bNew, bOld = newValue, oldValue + end) + + a:Set("health", 50) + + expect(bNew).is(50) + expect(bOld).is(100) + + a:Destroy() + b:Destroy() + end) + + test("array append with no listeners still updates live state", function() + local manager = TableManager.new { + items = { "a" }, + } + + manager:ArrayInsert("items", "b") + + expect(manager:Get("items")).is_shallow_equal { "a", "b" } + + manager:Destroy() + end) + + test("array append fires a listener registered before the write", function() + local manager = TableManager.new { + items = { "a" }, + } + + local insertedValue + manager:OnArrayInsert("items", function(_index, newValue) + insertedValue = newValue + end) + + manager:ArrayInsert("items", "b") + + expect(insertedValue).is("b") + + manager:Destroy() + end) + + test("listener registered mid-batch still observes an append made earlier in the batch", function() + local manager = TableManager.new { + items = { "a" }, + } + + local insertedValue + manager:Suspend() + manager:ArrayInsert("items", "b") + -- No listener existed yet when the append happened; the batch's + -- unconditional dirty-marking (not the observation gate) is what + -- must keep this change live until flush. + manager:OnArrayInsert("items", function(_index, newValue) + insertedValue = newValue + end) + manager:Resume() + + expect(insertedValue).is("b") + expect(manager:Get("items")).is_shallow_equal { "a", "b" } + + manager:Destroy() + end) + end) +end diff --git a/lib/tablemanager2/src/Tests/shadow.spec.luau b/lib/tablemanager2/src/Tests/shadow.spec.luau new file mode 100644 index 00000000..c91db930 --- /dev/null +++ b/lib/tablemanager2/src/Tests/shadow.spec.luau @@ -0,0 +1,408 @@ +--!strict +--[[ + Tests for Phase 2 of the coverage-tree plan: the persistent `Shadow` + mirror and `TableManager:Flush`, which replace the old per-write + CaptureSnapshot/CheckForChanges pair on the immediate (non-batched) write + path. Covers: + - `Shadow` in isolation (Get/Materialize/Reconcile/Release). + - JIT seeding: the first trackable write after a region becomes observed + diffs against the PRE-write value, not against a missing/nil baseline. + - The shadow staying in sync with live data across every write entry + point (Set, proxy, array ops, link fan-out, batched Resume). + - Proxy-vs-`Set` `oldValue` parity (the "single-source" rule). + - The public `Flush` for external-mutation detection. +]] + +return function(t: tiniest) + const TableManager = require("../TableManager") + const Shadow = require("../Shadow") + const Coverage = require("../Coverage") + + const test = t.test + const describe = t.describe + const expect = t.expect + + describe("Shadow", function() + test("Get returns nil for a never-materialized path", function() + const shadow = Shadow.new() + expect(shadow:Get({ "a" })).never_exists() + end) + + test("Materialize seeds a scalar leaf and Get returns it", function() + const shadow = Shadow.new() + const live = { a = { b = 5 } } + shadow:Materialize({ "a", "b" }, live) + expect(shadow:Get({ "a", "b" })).is(5) + end) + + test("Materialize deep-copies a table leaf (mutating live doesn't affect the mirror)", function() + const shadow = Shadow.new() + const live = { a = { x = 1 } } + shadow:Materialize({ "a" }, live) + live.a.x = 2 + expect(shadow:Get({ "a" }).x).is(1) + end) + + test("Reconcile refreshes the mirror to the current live value", function() + const shadow = Shadow.new() + const live = { a = 1 } + shadow:Materialize({ "a" }, live) + live.a = 2 + shadow:Reconcile({ "a" }, live) + expect(shadow:Get({ "a" })).is(2) + end) + + test("Reconcile at the root replaces the whole mirror", function() + const shadow = Shadow.new() + const live = { a = 1 } + shadow:Materialize({}, live) + expect(shadow:Get({ "a" })).is(1) + shadow:Reconcile({}, { a = 99 }) + expect(shadow:Get({ "a" })).is(99) + end) + + test("Release drops a materialized subtree", function() + const shadow = Shadow.new() + const live = { a = { b = 1 } } + shadow:Materialize({ "a", "b" }, live) + shadow:Release({ "a" }) + expect(shadow:Get({ "a" })).never_exists() + end) + + test("Materialize is idempotent on an already-materialized path", function() + const shadow = Shadow.new() + const live = { a = { b = 1, c = 2 } } + shadow:Materialize({ "a", "b" }, live) + shadow:Materialize({ "a", "c" }, live) + -- Both leaves survive: materializing "a.c" must not wipe out "a.b". + expect(shadow:Get({ "a", "b" })).is(1) + expect(shadow:Get({ "a", "c" })).is(2) + end) + + test("a newly-created intermediate node captures sibling keys never themselves materialized", function() + const shadow = Shadow.new() + const live = { player = { health = 100, mana = 5 } } + -- Only "health" is ever individually materialized; "mana" is a + -- sibling under the same new intermediate node ("player"). + shadow:Materialize({ "player", "health" }, live) + -- The intermediate "player" node must carry "mana" too, even though + -- nothing ever materialized {"player", "mana"} directly - otherwise a + -- later wholesale replacement of "player" couldn't detect its removal. + expect(shadow:Get({ "player", "mana" })).is(5) + end) + end) + + describe("Coverage.PassesThroughOpaqueAncestor", function() + test("false when nothing along the path is opaque", function() + const manager = TableManager.new { player = { hp = 100 } } + const ctx = (manager :: any)._changeDetector:GetOpaqueCtx() + expect(Coverage.PassesThroughOpaqueAncestor(manager :: any, { "player", "hp" }, ctx)).never_is_true() + manager:Destroy() + end) + + test("true when the direct parent is opaque", function() + const manager = TableManager.new { player = {} } + manager:Set("player", TableManager.Opaque({ hp = 100 })) + const ctx = (manager :: any)._changeDetector:GetOpaqueCtx() + expect(Coverage.PassesThroughOpaqueAncestor(manager :: any, { "player", "hp" }, ctx)).is_true() + manager:Destroy() + end) + + test("false for the opaque path itself (only DESCENDANTS of it count)", function() + const manager = TableManager.new { player = {} } + manager:Set("player", TableManager.Opaque({ hp = 100 })) + const ctx = (manager :: any)._changeDetector:GetOpaqueCtx() + expect(Coverage.PassesThroughOpaqueAncestor(manager :: any, { "player" }, ctx)).never_is_true() + manager:Destroy() + end) + end) + + describe("Shadow integration (TableManager)", function() + test("a direct write into an opaque descendant never corrupts the opaque object (repeated writes stay accurate)", function() + const manager = TableManager.new { player = {} } + manager:Set("player", TableManager.Opaque({ hp = 100 })) + + const seen: { any } = {} + manager:OnValueChange({ "player", "hp" }, function(newValue, oldValue) + table.insert(seen, { newValue, oldValue }) + end) + + -- If Materialize aliased the live opaque object instead of bypassing + -- the shadow for it, this first write would corrupt the "old" + -- baseline together with "new", making the SECOND write's oldValue + -- wrong too (it would read back whatever the first write just set). + manager:Set({ "player", "hp" }, 50) + manager:Set({ "player", "hp" }, 75) + + expect(seen[1][1]).is(50) + expect(seen[1][2]).is(100) + expect(seen[2][1]).is(75) + expect(seen[2][2]).is(50) + + manager:Destroy() + end) + + test("replacing an ancestor reports removal of a sibling key that was never itself written", function() + const manager = TableManager.new { + player = { health = 100, mana = 5 }, + } + + -- A global Signal connection (not a path-scoped registry listener) + -- makes every write independently trackable WITHOUT ever + -- materializing a whole subtree up front. "mana" is never itself + -- the target of a write below, only "health" is. + local removedKeys: { any } = {} + manager.KeyRemoved:Connect(function(_path, key) + table.insert(removedKeys, key) + end) + + manager:Set("player.health", 50) + manager:Set("player", { health = 1 }) + + expect(manager:Get("player")).is_shallow_equal { health = 1 } + expect(table.find(removedKeys, "mana")).exists() + + manager:Destroy() + end) + + test("the first trackable write after a listener registers diffs against the PRE-existing value", function() + const manager = TableManager.new { + a = { b = 5 }, + } + + -- "a.b" already had a real value (5) BEFORE any listener existed, so + -- the shadow was never materialized for it. The listener below makes + -- a write at "a" trackable for the FIRST time; without JIT-seeding the + -- shadow from the pre-write value, this would misreport "b" as having + -- gone from nil -> 6 (or as a spurious extra "added" key) instead of 5 -> 6. + local capturedOld: any + manager:OnValueChange("a", function(_newValue, oldValue) + capturedOld = oldValue + end) + + manager:Set("a", { b = 6 }) + + expect(capturedOld).exists() + expect(capturedOld.b).is(5) + + manager:Destroy() + end) + + test("proxy writes and :Set report identical oldValue (single-source rule)", function() + const manager = TableManager.new { + x = 1, + } + + const seenViaSet: { any } = {} + manager:OnValueChange("x", function(newValue, oldValue) + table.insert(seenViaSet, { newValue, oldValue }) + end) + + manager:Set("x", 2) + const proxy: any = manager.Proxy + proxy.x = 3 + + expect(seenViaSet[1][1]).is(2) + expect(seenViaSet[1][2]).is(1) + expect(seenViaSet[2][1]).is(3) + expect(seenViaSet[2][2]).is(2) + + manager:Destroy() + end) + + test("the shadow stays in sync after a Set so the NEXT unrelated flush isn't corrupted", function() + const manager = TableManager.new { + a = { value = 1 }, + z = 1, + } + + manager:OnValueChange("a", function() end) + manager:OnValueChange("z", function() end) + + manager:Set("a", { value = 2 }) + + -- A later, unrelated write at a DIFFERENT root branch must not see + -- stale shadow state for "a" leak into its own diff. + local zOld: any + manager:OnValueChange("z", function(_newValue, oldValue) + zOld = oldValue + end) + manager:Set("z", 5) + + expect(zOld).is(1) + expect((manager :: any)._shadow:Get({ "a" }).value).is(2) + + manager:Destroy() + end) + + test("array operations keep the shadow's mirror of the array in sync", function() + const manager = TableManager.new { + items = { "a", "b" }, + } + + -- Cover the array's PARENT so the array's own ops are forced trackable + -- and exercised through the shadow-sync added to fireArrayOperation. + manager:OnChange("items", function() end) + + manager:ArrayInsert("items", "c") + manager:ArrayRemove("items", 1) + + expect((manager :: any)._shadow:Get({ "items" })).is_shallow_equal { "b", "c" } + + manager:Destroy() + end) + + test("a coalesced batch leaves the shadow consistent with post-batch live state", function() + const manager = TableManager.new { + x = 1, + } + manager:OnValueChange("x", function() end) + + manager:Batch(function() + manager:Set("x", 2) + manager:Set("x", 3) + end) + + expect((manager :: any)._shadow:Get({ "x" })).is(3) + + -- A subsequent immediate write must diff against the post-batch + -- value (3), not a stale pre-batch one (1). + local capturedOld: any + manager:OnValueChange("x", function(_newValue, oldValue) + capturedOld = oldValue + end) + manager:Set("x", 4) + + expect(capturedOld).is(3) + + manager:Destroy() + end) + + test("a batch fires net changes once and suppresses net-zero changes", function() + const manager = TableManager.new { + x = 1, + } + local fireCount = 0 + local lastNew: any + manager:OnValueChange("x", function(newValue) + fireCount += 1 + lastNew = newValue + end) + + manager:Batch(function() + manager:Set("x", 2) + manager:Set("x", 3) + end) + expect(fireCount).is(1) + expect(lastNew).is(3) + + manager:Batch(function() + manager:Set("x", 99) + manager:Set("x", 3) + end) + expect(fireCount).is(1) -- net-zero (3 -> 99 -> 3): no second fire + + manager:Destroy() + end) + + test("link fan-out keeps the target manager's shadow in sync", function() + const shared = { health = 100 } + const a = TableManager.new(shared) + const b = TableManager.new(shared) + TableManager.Link({ { a }, { b } }) + + b:OnValueChange("health", function() end) + a:Set("health", 50) + + expect((b :: any)._shadow:Get({ "health" })).is(50) + + -- A later write on `a` must diff against the now-current value (50) + -- from `b`'s perspective too, not the stale original (100). + local bOld: any + b:OnValueChange("health", function(_newValue, oldValue) + bOld = oldValue + end) + a:Set("health", 25) + expect(bOld).is(50) + + a:Destroy() + b:Destroy() + end) + + test("Flush surfaces a mutation made by code that bypassed Set/Proxy", function() + const manager = TableManager.new { + a = { value = 1 }, + } + + local captured: { any } = {} + manager:OnValueChange("a", function(newValue, oldValue) + captured = { newValue, oldValue } + end) + + -- Force the shadow to materialize, then mutate the raw table directly, + -- bypassing Set/Proxy entirely. + manager:Set("a", { value = 1 }) + const raw: any = manager.Raw + raw.a = { value = 2 } + + manager:Flush("a") + + expect(captured[1].value).is(2) + expect(captured[2].value).is(1) + + manager:Destroy() + end) + + test("Flush is a no-op when nothing observes the given path", function() + const manager = TableManager.new { + a = 1, + } + -- Must not error even though nothing covers "a". + expect(function() + manager:Flush("a") + end).never_fails() + manager:Destroy() + end) + + test("an explicit Flush inside a batch defers instead of firing immediately", function() + const manager = TableManager.new { + x = 1, + } + local fireCount = 0 + manager:OnValueChange("x", function() + fireCount += 1 + end) + + manager:Suspend() + manager:Set("x", 2) + manager:Flush("x") + expect(fireCount).is(0) -- still suspended; Flush must not break batch atomicity + manager:Resume() + expect(fireCount).is(1) + + manager:Destroy() + end) + + test("repeated churn on an observed path does not grow the shadow's mirror size", function() + const manager = TableManager.new { + n = 0, + } + manager:OnValueChange("n", function() end) + + for i = 1, 50 do + manager:Set("n", i) + end + + -- The mirror for a single scalar path is always exactly one slot; + -- this mostly guards against Materialize accidentally accumulating + -- stale sibling entries on repeated calls. + local count = 0 + for _ in (manager :: any)._shadow._mirror do + count += 1 + end + expect(count).is(1) + + manager:Destroy() + end) + end) +end diff --git a/lib/tablemanager/wally.toml b/lib/tablemanager2/wally.toml similarity index 52% rename from lib/tablemanager/wally.toml rename to lib/tablemanager2/wally.toml index ae3fbe59..5dc04819 100644 --- a/lib/tablemanager/wally.toml +++ b/lib/tablemanager2/wally.toml @@ -1,8 +1,8 @@ [package] -name = "raild3x/tablemanager" -description = "A class for managing and observing data in a table. Includes some additional classes for extending functionality." +name = "raild3x/tablemanager2" +description = "A better version of tablemanager" authors = ["Logan Hunt (Raildex)"] -version = "0.2.2" +version = "0.1.0" license = "MIT" registry = "https://github.com/UpliftGames/wally-index" realm = "shared" @@ -14,8 +14,6 @@ formattedName = "TableManager" docsLink = "TableManager" [dependencies] -Promise = "evaera/promise@^4.0.0" -Fusion = "elttob/fusion@0.3.0" -Signal = "lucasmzreal/fastsignal@^10.2.1" -BaseObject = "raild3x/baseobject@^0.1" +Signal = "howmanysmall/better-signal@2.1.0" +T = "raild3x/t@^1" Janitor = "howmanysmall/janitor@^1.16.0" \ No newline at end of file diff --git a/moonwave.toml b/moonwave.toml index c5258e47..901a02be 100644 --- a/moonwave.toml +++ b/moonwave.toml @@ -13,8 +13,7 @@ classes = ["BaseObject"] classes = ["Roam"] [[classOrder]] -section = "TableManager" -classes = ["TableManager", "TableState"] +classes = ["TableManager"] [[classOrder]] section = "TableReplicator" diff --git a/scripts/convert_requires_to_string_format.py b/scripts/convert_requires_to_string_format.py new file mode 100644 index 00000000..fc09fcbf --- /dev/null +++ b/scripts/convert_requires_to_string_format.py @@ -0,0 +1,136 @@ +import re +from pathlib import Path + +REQUIRE_PATTERN = re.compile( + r"require\s*\(\s*(.*?)\s*\)", + re.DOTALL +) + + +def parse_instance_path(expr: str): + """ + Attempts to convert a Roblox Instance expression into a list of path parts. + + Returns: + ("self", ["Child", "GrandChild"]) + ("parent", depth, ["Child"]) + None if expression is not statically resolvable. + """ + + expr = expr.strip() + + if not expr.startswith("script"): + return None + + pos = len("script") + + parent_depth = 0 + parts = [] + + while pos < len(expr): + remaining = expr[pos:] + + # .Name + m = re.match(r"\.([A-Za-z_][A-Za-z0-9_]*)", remaining) + if m: + name = m.group(1) + + if name == "Parent" and not parts: + parent_depth += 1 + else: + parts.append(name) + + pos += m.end() + continue + + # ["Name"] + m = re.match(r'\["([^"]+)"\]', remaining) + if m: + parts.append(m.group(1)) + pos += m.end() + continue + + # :FindFirstChild("Name") + m = re.match( + r':FindFirstChild\(\s*"([^"]+)"\s*\)', + remaining + ) + if m: + parts.append(m.group(1)) + pos += m.end() + continue + + # :WaitForChild("Name") + m = re.match( + r':WaitForChild\(\s*"([^"]+)"\s*\)', + remaining + ) + if m: + parts.append(m.group(1)) + pos += m.end() + continue + + # unsupported dynamic expression + return None + + if parent_depth == 0: + return ("self", parts) + + return ("parent", parent_depth, parts) + + +def convert_require_path(expr: str): + parsed = parse_instance_path(expr) + + if parsed is None: + return None + + kind = parsed[0] + + if kind == "self": + _, parts = parsed + return f'@self/{"/".join(parts)}' + + _, depth, parts = parsed + + if depth == 1: + prefix = "." + else: + prefix = "/".join(".." for _ in range(depth - 1)) + + if parts: + return f'{prefix}/{"/".join(parts)}' + + return prefix + + +def replace_requires(source: str): + def repl(match): + expr = match.group(1) + + converted = convert_require_path(expr) + + if converted is None: + return match.group(0) + + return f'require("{converted}")' + + return REQUIRE_PATTERN.sub(repl, source) + + +def process_file(path: Path): + original = path.read_text(encoding="utf-8") + + transformed = replace_requires(original) + + if transformed != original: + path.write_text(transformed, encoding="utf-8") + print(f"Updated: {path}") + + +def process_directory(root: str): + root = Path(root) + + for file in root.rglob("*"): + if file.suffix in (".lua", ".luau"): + process_file(file) diff --git a/scripts/generateReadMe.py b/scripts/generateReadMe.py index 5618874e..7ae48039 100644 --- a/scripts/generateReadMe.py +++ b/scripts/generateReadMe.py @@ -178,6 +178,7 @@ def main(): readme_content += f"\n---\n\n*Last Modified: {date.today().strftime('%B %d, %Y')}*\n" readme_file.write_text(readme_content, encoding="utf-8") + print(readme_content) print("\nREADME.md has been generated successfully.") return 0 diff --git a/scripts/setup_package_for_testing.py b/scripts/setup_package_for_testing.py index b5e35be1..aa578f6e 100644 --- a/scripts/setup_package_for_testing.py +++ b/scripts/setup_package_for_testing.py @@ -7,6 +7,7 @@ import os import shutil import sys +from convert_requires_to_string_format import process_directory from pathlib import Path # --------------------------------------------------------------------------- @@ -55,6 +56,8 @@ def setup_package(package_dir: Path) -> bool: ): return False + process_directory(packages_dir) + # Move files out of Packages directory print("Moving Wally Packages out of Packages directory...") for item in packages_dir.iterdir(): diff --git a/test/runTestEZ_Roblox.server.luau b/test/runTestEZ_Roblox.server.luau deleted file mode 100644 index ea777aa2..00000000 --- a/test/runTestEZ_Roblox.server.luau +++ /dev/null @@ -1,4 +0,0 @@ --- print("Running TestEZ tests...") --- require("./TestEZ").TestBootstrap:run { --- -- game.ServerScriptService.src.RailUtil, --- } diff --git a/test/runTiniest_Roblox.server.luau b/test/runTiniest_Roblox.server.luau index 31f014d4..04cd20d9 100644 --- a/test/runTiniest_Roblox.server.luau +++ b/test/runTiniest_Roblox.server.luau @@ -62,11 +62,13 @@ local tiniest = require("./tiniest/tiniest_for_roblox").configure {} local ReplicatedStorage = game:GetService("ReplicatedStorage") -local PACKAGE_TO_TEST = "delaunay" -- Change this to the name of the package you want to test -local folderToSearch = ReplicatedStorage.src:FindFirstChild(PACKAGE_TO_TEST) -assert(folderToSearch, "Failed to find package") -local tests = tiniest.collect_tests_from_hierarchy(folderToSearch.src["2dConstrained_New"]) -tiniest.run_tests(tests, { - timeout_diagnostics = "on_error", - safe_mode = true, +local PACKAGE_TO_TEST = "tablemanager2" -- Change this to the name of the package you want to test +local tests = tiniest.collect_tests_from_hierarchy(ReplicatedStorage.src:FindFirstChild(PACKAGE_TO_TEST), { + ignore = function(obj: Instance) + if obj.Name == "_Index" then + return true, true -- ignore this instance and its descendants + end + return false, false -- do not ignore + end, }) +tiniest.run_tests(tests, {}) diff --git a/test/tiniest/tiniest_for_roblox.luau b/test/tiniest/tiniest_for_roblox.luau index 7c4b164d..e28787e4 100644 --- a/test/tiniest/tiniest_for_roblox.luau +++ b/test/tiniest/tiniest_for_roblox.luau @@ -77,6 +77,7 @@ export type Options = { } export type CollectOptions = { + ignore: (Instance) -> (boolean?, boolean?)?, -- ignoreSelf, ignoreDescendants file_name_pattern: string?, } @@ -151,6 +152,9 @@ function tiniest_for_roblox.configure(options: Options) ## CollectOptions - `file_name_pattern: string?` -- Lua pattern to match test files (defaults to "%.spec$") + - `ignore: ((Instance) -> (boolean?, boolean?))?` -- Called for every Instance visited during + discovery. Return `ignoreSelf` to skip describing/collecting the Instance itself, and + `ignoreDescendants` to skip recursing into its children. ## Example ```lua @@ -180,7 +184,32 @@ function tiniest_for_roblox.configure(options: Options) function self.collect_tests_from_hierarchy(ancestor: Instance, declared_collect_options: CollectOptions?) local collect_options: CollectOptions = declared_collect_options or {} local file_name_pattern = collect_options.file_name_pattern or "%.spec$" - local function discover(ancestor: Instance): () + local ignore = collect_options.ignore + local discover: (ancestor: Instance) -> () + local function discoverChildren(ancestor: Instance): () + local children = ancestor:GetChildren() + if #children > 0 then + table.sort(children, function(a: Instance, b: Instance) + return a.Name < b.Name + end) + for _, child in children do + discover(child) + end + end + end + function discover(ancestor: Instance): () + local ignoreSelf: boolean?, ignoreDescendants: boolean? + if ignore then + ignoreSelf, ignoreDescendants = ignore(ancestor) + end + + if ignoreSelf then + if not ignoreDescendants then + discoverChildren(ancestor) + end + return + end + self.describe(ancestor.Name, function() if ancestor:IsA("ModuleScript") and ancestor.Name:match(file_name_pattern) then local requireOk, module = pcall(require, ancestor) @@ -194,14 +223,8 @@ function tiniest_for_roblox.configure(options: Options) error(err, 0) end end - local children = ancestor:GetChildren() - if #children > 0 then - table.sort(children, function(a: Instance, b: Instance) - return a.Name < b.Name - end) - for _, child in children do - discover(child) - end + if not ignoreDescendants then + discoverChildren(ancestor) end end) end