{"id":"rowdy176/steward","name":"steward","scope":"rowdy176","platform":"roblox","description":"Steward is a hierarchical lifetime ownership framework.","version":"0.1.6","latest":"0.1.6","versions":["0.1.0","0.1.1","0.1.3","0.1.4","0.1.5","0.1.6"],"license":"MIT","licenseRating":"safe","licenseCaveats":[],"licenseVerified":true,"dependencies":{},"integrity":"99f81e279a2c85905acd93508218b74887bef0ccb1329c2c231e3ce0b93b60b7","likes":0,"downloads":0,"install":"forest install rowdy176/steward","url":"https://forest.dev/p/roblox/rowdy176/steward","files":"https://api.forest.dev/ai/package/roblox/rowdy176/steward/files","readme":"# Introduction\r\n\r\nYou've probably written code like this:\r\n\r\n```\r\nMatch\r\n├── Team A\r\n│   ├── Player\r\n│   │   ├── Character\r\n│   │   ├── Connections\r\n│   │   ├── UI\r\n│   │   └── Threads\r\n│   └── ...\r\n└── ...\r\n```\r\n\r\nNow ask yourself:\r\n\r\n- When the Player leaves, what gets cleaned up?\r\n- When the Team is removed, what gets cleaned up?\r\n- When the Match ends, what gets cleaned up?\r\n\r\nMost cleanup libraries don't answer that question.\r\n\r\nThey answer a different one:\r\n> \"How do I clean up this collection of resources?\"\r\n\r\nInstead, consider a different question:\r\n> \"Who owns these resources and is responsible for their lifetime?\"\r\n\r\nBecause once ownership is explicit, cleanup becomes predictable.\r\n\r\n# Steward\r\n\r\nSteward is a hierarchical lifetime ownership framework.\r\n\r\nI want to be clear about that phrase because it's not just yap to sound professional.\r\nSteward quite literally is a hierarchical lifetime ownership framework, that cleans children when the owner is dismissed.\r\nThis is not another cleanup library. Cleanup is not the feature. Ownership\r\nis the feature, and it's hierarchical, meaning a Steward can own other\r\nStewards, not just connections and instances. Cleanup just happens to fall\r\nout of that for free.\r\n\r\n## Why the name?\r\n\r\nA steward is responsible for managing something on behalf of another. \r\nIn the same way, every Steward is responsible for the lifetime of the resources it owns.\r\nThat's pretty much why, since that's basically what my library is, so yeah.\r\n\r\n## Table of Contents\r\n\r\n- [Why this exists](#why-this-exists)\r\n- [Installation](#installation)\r\n- [Quick Start](#quick-start)\r\n- [How it actually works](#how-it-actually-works)\r\n  - [Ownership, not cleanup](#ownership-not-cleanup)\r\n  - [Nesting](#nesting)\r\n  - [Adoption](#adoption)\r\n  - [Dismissal](#dismissal)\r\n  - [Attaching to an Instance](#attaching-to-an-instance)\r\n- [API Reference](#api-reference)\r\n- [Debugging a Hierarchy](#debugging-a-hierarchy)\r\n- [Good & Bad Practices](#good--bad-practices)\r\n- [Contributing](#contributing)\r\n- [Safety Notes](#safety-notes)\r\n\r\n## Why this exists\r\n\r\nMost Roblox cleanup libraries give you a\r\nbag. You throw connections and instances into the bag, and when the bag\r\ndies, everything in it dies too. That's fine for one object, but it doesn't\r\nreally model anything. It's just a flat list.\r\n\r\nYour game isn't flat though. A Match has Teams. Teams have Players. Players\r\nhave Characters, connections, UI, whatever. That's a tree, whether you\r\nwrite it down or not. Steward just lets you write it down. You build a\r\nSteward tree that mirrors your actual game state, and when a branch of your\r\ngame ends (a player leaves, a round ends, a match ends), you call\r\n`Dismiss()` once on the right node and the whole branch, connections,\r\ninstances, child Stewards, all of it, goes down in order.\r\n\r\nSounds simple, because it is.\r\n\r\nPlus, you get very simple debugging tools to see the entire Steward tree as you wish to see **WHY** your game is leaking memory.\r\nIsn't that amazing? I think it is!\r\n\r\n## Installation\r\n\r\nSteward ships as three ModuleScripts, with Types and Debug living under the\r\nmain Steward script:\r\n\r\n```\r\nSteward (ModuleScript)\r\n├── Types (ModuleScript)\r\n└── Debug (ModuleScript)\r\n```\r\n\r\nDrop the folder wherever you keep your modules and require the init:\r\n\r\n```luau\r\nconst Steward = require(ReplicatedStorage.Packages.Steward.init)\r\n```\r\n\r\nYou never need or should touch Types or Debug directly. Types just holds type\r\ndefinitions and Debug is explicitly internal, it even says so in its own\r\ndoc comment. Use `Steward:Dump()`, `Steward:Snapshot()`, and\r\n`Steward.Diff()` instead.\r\n\r\n## Quick Start\r\n\r\n```luau\r\nconst Steward = require(ReplicatedStorage.Packages.Steward.init)\r\n\r\nconst Match = Steward.new(\"Match\")\r\n\r\nconst TeamA = Match:Nest(\"TeamA\")\r\nconst Player = TeamA:Nest(\"Player_\" .. userId)\r\n\r\nPlayer:AdoptInstance(characterModel)\r\nPlayer:AdoptConnection(humanoid.Died:Connect(onDeath))\r\nPlayer:AddCleanup(function()\r\n\tprint(userId, \"left the match\")\r\nend)\r\n\r\n--// Basically, Match and everything under it is fully removed, disconnected or destroyed\r\nMatch:Dismiss()\r\n```\r\n\r\nThat's it. No separate teardown function to maintain, no forgetting to\r\ndisconnect something that you forgot exists\r\n\r\n## How it actually works\r\n\r\n### Ownership, not cleanup\r\n\r\nEvery Steward is a node. A node can hold resources (connections, threads,\r\ntweens, instances, arbitrary destroyable or disconnectable objects, plain\r\nfunctions) and it can hold other Stewards as children. Something is owned\r\nthe second it's adopted, and it stays owned by exactly one Steward until\r\neither that Steward dismisses or the resource gets moved elsewhere.\r\n\r\n### Nesting\r\n\r\n```lua\r\nParent:Nest(nameOrChild?)\r\n```\r\n\r\nThis is how you actually build the tree. Pass a name or nothing and you\r\nget a fresh child Steward back. Pass an existing Steward and it gets\r\nreparented under the caller, automatically detached from wherever it was\r\nbefore.\r\n\r\nA couple things it guards against:\r\n\r\n- **Cycles.** Nesting a Steward under itself or one of its own descendants\r\n  gets refused with a warning. The tree can't fold in on itself.\r\n- **Nesting into a dead branch.** If the parent is already dismissed, the\r\n  child gets dismissed immediately too. You can't attach fresh state to a\r\n  branch that's already gone.\r\n\r\n### Adoption\r\n\r\nAdoption is how a resource that isn't a Steward gets pulled into the tree.\r\nThere's a specific method per kind, plus a generic `Adopt` that figures out\r\nwhat you handed it:\r\n\r\n| Method | Takes | On dismissal |\r\n|---|---|---|\r\n| `AdoptConnection` | `RBXScriptConnection` | Disconnected |\r\n| `AdoptThread` | `thread` | Cancelled |\r\n| `AdoptTween` | `Tween` | Cancelled, then destroyed |\r\n| `AdoptInstance` | any other `Instance` | Destroyed |\r\n| `AdoptDestroyable` | table with `:Destroy()` | `:Destroy()` called |\r\n| `AdoptDisconnectable` | table with `:Disconnect()` | `:Disconnect()` called |\r\n| `AddCleanup` | plain `function` | called |\r\n| `Nest` | another `Steward` | dismissed as a child |\r\n\r\n`Adopt` checks the type of whatever you hand it and routes it to the right\r\nmethod above. It's convenient for generic code paths where you don't\r\nstatically know what you're getting. If you already know, use the specific\r\nmethod, it's more explicit and skips the type sniffing.\r\n\r\nAdopting into a Steward that's already dismissed doesn't get quietly\r\ndropped, it gets torn down right there on the spot, same as if it had been\r\nowned the whole time and the parent just dismissed. Ownership rules hold\r\neven at the edges.\r\n\r\n### Dismissal\r\n\r\n```lua\r\nSteward:Dismiss()\r\n```\r\n\r\nThis is where ownership turns into action. Calling it tears things down in\r\nthis order:\r\n\r\n1. Connections from `AttachTo`\r\n2. Adopted connections\r\n3. Adopted threads\r\n4. Adopted tweens\r\n5. Every child Steward, recursively, running this same order\r\n6. Adopted instances\r\n7. Adopted destroyables\r\n8. Adopted disconnectables\r\n9. Adopted cleanup functions\r\n\r\nAfter that it removes itself from its own parent's child list, so nothing\r\nabove it keeps a dangling pointer to a dead node.\r\n\r\n`Dismiss()` is idempotent, calling it again does nothing. There's no other\r\nconcept of \"cleanup\" beyond this. You dismiss a node because its part of\r\nthe tree is over, and whatever it owned goes with it.\r\n\r\nIncase you are wondering how it really looks like in action, here is an example:\r\n\r\n```\r\nRoot\r\n-> Match\r\n--> TeamA\r\n---> Player_1\r\n----> Character\r\n-----> 1 Connection\r\n-----> 1 Thread\r\n```\r\n\r\nCall `Dismiss()` once on TeamA. Let's see what that does:\r\n\r\n```\r\nRoot\r\n-> Match\r\n```\r\n\r\nDang, see that? TeamA and everything underneath is GONE.\r\n\r\n### Attaching to an Instance\r\n\r\n```lua\r\nSteward:AttachTo(instance)\r\n```\r\n\r\nSometimes you want a Steward's life tied to something outside the tree\r\nentirely, like a Player object or a spawned Model. `AttachTo` hooks the\r\nInstance's `Destroying` event and calls `Dismiss()` when it fires, so you\r\ndon't have to remember to do it by hand.\r\n\r\n## API Reference\r\n\r\n- **`Steward.new(name: string?) -> Steward`**\r\nMakes a new, unparented Steward. No name given, it gets one auto-generated\r\nso it's still identifiable later in a Dump.\r\n- **`Steward:AdoptConnection(connection) -> RBXScriptConnection`**\r\n- **`Steward:AdoptThread(thread) -> thread`**\r\n- **`Steward:AdoptTween(tween) -> Tween`**\r\n- **`Steward:AdoptInstance(instance) -> Instance`**\r\n- **`Steward:AdoptDestroyable(object) -> object`**\r\nWarns and hands the object back untouched if it has no `Destroy` method.\r\n- **`Steward:AdoptDisconnectable(object) -> object`**\r\nSame deal but for `Disconnect`.\r\n- **`Steward:AddCleanup(fn) -> fn`**\r\n- **`Steward:Adopt(resource) -> resource`**\r\nAuto-detects and routes to whichever method above fits, including nesting\r\nif you hand it a Steward. Warns if it genuinely doesn't recognize the type.\r\n- **`Steward:Nest(nameOrChild: (string | Steward)?) -> Steward`**\r\nBuilds or reparents into the tree.\r\n- **`Steward:AttachTo(instance: Instance) -> Steward`**\r\nTies dismissal to an Instance's `Destroying` event. Returns self so you can\r\nchain it.\r\n- **`Steward:Dismiss() -> ()`**\r\nTears the node and its whole branch down.\r\n- **`Steward:Snapshot() -> Snapshot`**\r\n- **`Steward:Dump() -> string`**\r\n- **`Steward.Diff(a: Snapshot, b: Snapshot) -> string`**\r\nCovered below.\r\n\r\n## Debugging a Hierarchy\r\n\r\nBecause the tree actually mirrors your game state, it doubles as a\r\ndiagnostic tool without any extra setup. `Snapshot()` grabs the shape of a\r\nbranch, names and resource counts, recursively, without holding onto the\r\nactual resources. Keeping a snapshot around never keeps anything alive.\r\n\r\n```luau\r\nconst before = Match:Snapshot()\r\n\r\n--// Stuff happens, connections, threads and all that blah blah blah\r\n\r\nconst after = Match:Snapshot()\r\n\r\nprint(Steward.Diff(before, after))\r\n```\r\n\r\n`Dump()` prints (and returns) a readable indented tree of a branch right\r\nnow:\r\n\r\n```\r\nMatch\r\n->TeamA\r\n--> 2 Connections\r\n--> Player_1\r\n---> 1 Connection\r\n---> 1 Instance\r\n```\r\n\r\n`Diff()` only reports what changed between two snapshots, added nodes,\r\nremoved nodes, and per-node count deltas. Good for catching a branch that's\r\nquietly piling up connections it was never supposed to keep.\r\n\r\n## Good & Bad Practices\r\nSince Steward is pretty new, a Good & Bad Practices section would be nice I thought.\r\n\r\n✅ **Good: organize long lived root Stewards.**\r\n\r\nIf you have Stewards that are expected to live for the lifetime of the\r\nserver or client (for example a root Steward or a player root), keeping\r\nthem in a shared registry is perfectly reasonable. It gives the rest of\r\nyour codebase a well known entry point into the ownership tree.\r\n\r\nAvoid putting temporary Stewards into the registry. The ownership tree\r\nitself should describe what currently exists. The registry should only\r\nhold long lived roots into that tree.\r\n\r\n✅ **Good: name your Stewards.**\r\n\r\n`Dump()` and `Diff()` lean on names to make output readable. Leave\r\neverything unnamed and you'll be staring at a dump full of `Steward#42`\r\nand `Steward#87` trying to guess what is what. Name things after what they represent (`\"Match\"`,\r\n`\"Player_\" .. userId`) and future you will thank you.\r\n\r\n✅ **Good: build the tree to match your actual game structure.**\r\n\r\nThe whole point is that one `Dismiss()` call on the right node tears down\r\na meaningful chunk of state. If you shove everything under one giant root\r\nSteward for the entire server lifetime, you lose the ability to tear down\r\njust a round or just a player, you're back to manually tracking what\r\nbelongs to what. On the flip side, don't leave a bunch of `Steward.new()`\r\ncalls floating around unnested either, an orphaned Steward with no parent\r\nnever gets dismissed unless you remember to dismiss it yourself, which is\r\nexactly the manual bookkeeping this whole thing exists to avoid.\r\n\r\n✅ **Good: let `AddCleanup` closures capture their own state.**\r\n\r\nThe type doc for `CleanupFn` says it straight up, it takes no arguments,\r\nso anything it needs should be captured by the closure. Don't write a\r\ncleanup function that reaches out to some external variable that might\r\nalready be nil or already torn down by the time dismissal actually runs.\r\n\r\n✅ **Good: use `Snapshot()` and `Diff()` at natural checkpoints.**\r\n\r\nRound start, round end, player join, player leave, whatever your game's\r\nnatural boundaries are. Snapshotting there and diffing against the last one\r\nis a much faster way to catch a leak than manually auditing counts, and it\r\ncosts you basically nothing since snapshots don't hold resources alive.\r\n\r\n❌ **Bad: assuming your `AddCleanup` function runs before your children or\r\nyour own instances are gone.**\r\n\r\nLook at the dismissal order again. Children get dismissed at step 5.\r\nYour own instances, destroyables, disconnectables, and cleanup functions\r\nrun after, at steps 6 through 9. If your cleanup function needs a child\r\nSteward's state to still be intact, it won't be. Cleanup functions run\r\ndead last.\r\n\r\n❌ **Bad: mistaking a warning for a hard failure.**\r\n\r\n`AdoptDestroyable` and `AdoptDisconnectable` don't error if you hand them\r\nsomething without the right method, they warn and just hand the object\r\nback, unowned. If you don't check your output for warnings, you can end\r\nup thinking something's tracked when it never actually got adopted. Same\r\nthing with `Nest` refusing a cycle, it warns and returns the child\r\nuntouched, it doesn't throw. Keep an eye on your warnings, this library\r\nuses them as a real signal, not decoration.\r\n\r\n❌ **Bad: calling `AdoptInstance` directly on a Tween.**\r\n\r\nTweens are technically Roblox Instances, which is why `Adopt()` specifically\r\nchecks `IsA(\"Tween\")` before deciding which bucket to put it in. If you\r\nbypass that and call `AdoptInstance` on a Tween yourself, it only gets\r\n`:Destroy()` called on it, it never gets `:Cancel()`'d first. Use `Adopt()`\r\nor `AdoptTween()` directly for tweens and let the library make that call.\r\n\r\n❌ **Bad: relying on a failed cleanup to bubble up as an error.**\r\n\r\nEvery single teardown call, connections, instances, destroyables, cleanup\r\nfunctions, all of it, is wrapped in `pcall`. If one of them throws, you get\r\na warning in the output and the rest of dismissal just keeps going. That's\r\na good thing for reliability, one broken resource can't wreck a whole\r\nbranch's teardown, but it also means you can't catch a cleanup failure the\r\nnormal way. If something in your cleanup absolutely must be verified, do\r\nthat check somewhere other than inside the cleanup function itself.\r\n\r\n## Contributing\r\n\r\nBug reports, documentation improvements, performance optimizations, and\r\nfeatures that strengthen Steward's ownership model are always welcome.\r\n\r\nBefore opening a pull request that changes the API or Steward's behavior,\r\nplease make sure it fits Steward's philosophy.\r\n\r\nSteward has a deliberately opinionated design. New features should support\r\nits core philosophy of explicit hierarchical ownership rather than turning\r\nit into a general purpose utility library.\r\n\r\n## Safety Notes\r\n\r\n- Every teardown call is pcall wrapped. One bad `:Destroy()` won't stop the\r\n  rest of a branch from tearing down.\r\n- `Dismiss()` is idempotent. Call it twice, nothing bad happens.\r\n- The tree can't be corrupted into a cycle, `Nest` checks ancestry first.\r\n- Adopting into an already dismissed Steward tears the resource down right\r\n  away instead of quietly holding onto it.\r\n","readmeTruncated":false}