Skip to content

API EntityPersistence

iKryptonic edited this page May 3, 2026 · 10 revisions

API: EntityPersistence

High-level controller that serializes entity state into a versioned JSON envelope and stores it through the DataStoreSystem entity.

Important

Audit scope (2026-05): This page was verified against:

  • FSM/Orchestrator/Core/Factory/PersistenceManager/EntityPersistence.luau
  • FSM/Orchestrator/Core/Factory/PersistenceManager/init.luau
  • FSM/Orchestrator/Core/Factory/BaseEntity.luau
  • FSM/Orchestrator/Core/DataStore/Entity/DataStoreEntity.luau
  • FSM/Orchestrator/Core/Settings.luau

EntityPersistence talks to a runtime DataStore facade whose actual call shape is DataStoreReference:GetAsync(datastoreName, key, maxRetries?), not the type-only DataStore:GetAsync(key) form described in Types.luau.


🎯 Overview

EntityPersistence sits between BaseEntity serialization and Roblox DataStores.

  • Save calls entity:Serialize(), wraps the result in a JSON envelope, and writes it.
  • Load reads the raw payload, decodes it, and calls entity:Deserialize(data).
  • Update performs an atomic UpdateAsync mutation on the stored data field.
  • Delete removes the key.

Most code should not instantiate the controller manually. Use the singleton facade exposed through Factory.PersistenceManager:

  • PersistenceManager.LoadState(entity, key)
  • PersistenceManager.SaveState(entity)

That wrapper lazily resolves the server-created DataStoreSystem entity and stamps _IsPersistent / _PersistenceKey into entity context.


🧱 Runtime Types

PersistenceConfig

type RuntimeDataStoreReference = {
    GetAsync: (self: any, datastoreName: string, key: string, maxRetries: number?) -> any,
    SetAsync: (self: any, datastoreName: string, key: string, value: any, maxRetries: number?) -> any,
    UpdateAsync: (self: any, datastoreName: string, key: string, transform: (any) -> any, maxRetries: number?) -> any,
    RemoveAsync: (self: any, datastoreName: string, key: string, maxRetries: number?) -> any,
}

type PersistenceConfig = {
    DataStoreName: string,
    KeyPrefix: string?,
    DataStoreReference: RuntimeDataStoreReference,
}

Stored envelope shape

{
  "version": 1,
  "updatedAt": 1706240000,
  "data": { "...persisted fields...": "..." },
  "meta": { "...caller metadata...": "..." }
}

Note

The envelope version is hard-coded to 1 by encodePayload(...). It is not the runtime entity version used by replication (entity.Version, _v, _versionHistory). See Versioning.


πŸ”„ Save / Load Data Flow

Load path
  Caller          PersistenceManager     EntityPersistence      Entity        DataStoreSystem    DataStoreService
    β”‚                    β”‚                      β”‚                 β”‚                 β”‚                  β”‚
    │─ LoadState(entity, key) ─→│              β”‚                 β”‚                 β”‚                  β”‚
    β”‚                    │─ Load(entity, key) ─→│                β”‚                 β”‚                  β”‚
    β”‚                    β”‚                      │─ GetAsync(DataStoreName, KeyPrefix:key) ─→│         β”‚
    β”‚                    β”‚                      β”‚                 β”‚                 │─ GetAsync(key) ─→│
    β”‚                    β”‚                      β”‚                 β”‚                 │← raw payload / nil ─│
    β”‚                    β”‚                      │← raw payload / error ────────────│                  β”‚
    β”‚                    β”‚                      β”‚ decodePayload(raw)                β”‚                  β”‚
    β”‚                    β”‚                      │─ Deserialize(data) ─────────────→│                  β”‚
    β”‚                    │← (ok, data, err) ───│                 β”‚                 β”‚                  β”‚

Save path
  Caller          PersistenceManager     EntityPersistence      Entity        DataStoreSystem    DataStoreService
    β”‚                    β”‚                      β”‚                 β”‚                 β”‚                  β”‚
    │─ SaveState(entity) ─→│                   β”‚                 β”‚                 β”‚                  β”‚
    β”‚                    │─ Save(entity, key) ─→│                β”‚                 β”‚                  β”‚
    β”‚                    β”‚                      │─ Serialize() ───────────────────→│                  β”‚
    β”‚                    β”‚                      β”‚ encodePayload(data, meta)        β”‚                  β”‚
    β”‚                    β”‚                      │─ SetAsync(DataStoreName, KeyPrefix:key, json) ─→│  β”‚
    β”‚                    β”‚                      β”‚                 β”‚                 │─ SetAsync(key, json) ─→│
    β”‚                    β”‚                      β”‚                 β”‚                 │← result / error ─────│
    β”‚                    β”‚                      │← result / error ─────────────────│                  β”‚
    β”‚                    │← (ok, err) ─────────│                 β”‚                 β”‚                  β”‚

πŸ”§ API Reference

EntityPersistence.Initialize(Util: any) -> ()

Initializes module-level logging.

  • Must run before EntityPersistence.new(...).
  • Idempotent.
  • Called automatically by PersistenceManager.Initialize(Util).

EntityPersistence.new(config: PersistenceConfig) -> EntityPersistence

Constructs a controller bound to one DataStore name, one key prefix, and one concrete runtime facade.

  • Parameters:
    • config.DataStoreName (string)
    • config.KeyPrefix (string?)
    • config.DataStoreReference (RuntimeDataStoreReference)
  • Asserts:
    • module initialized,
    • DataStoreName present,
    • DataStoreReference present.
  • Returns: controller with Save, Load, Update, Delete.
local controller = EntityPersistence.new({
    DataStoreName = "EntityPersistence",
    KeyPrefix = "Entity",
    DataStoreReference = Orchestrator.GetEntity("DataStoreSystem"),
})

controller:Save(entity: any, key: string, metadata: { [string]: any }?) -> (boolean, string?)

Serializes the entity and writes a JSON envelope to makeKey(KeyPrefix, key).

Exact behavior

  1. Verifies entity.Serialize exists.
  2. Calls entity:Serialize().
  3. Calls encodePayload(data, metadata).
  4. Calls:
    DataStore:SetAsync(config.DataStoreName, makeKey(config.KeyPrefix, key), payload)
  5. Returns (true, nil) on success.

Failure modes

Error Trigger
EntityMissingSerialize entity missing or has no Serialize method
SerializeFailed HttpService:JSONEncode(...) threw
DatastoreFail SetAsync missing or the runtime call threw

Edge cases

  • metadata defaults to {} inside the envelope.
  • The actual return value from Roblox SetAsync is ignored; only thrown errors matter.
  • There is no framework throttle here.
local ok, err = controller:Save(playerEntity, "Player_123", {
    ServerJobId = game.JobId,
    SavedBy = "PlayerRemoving",
})

if not ok then
    warn("Save failed:", err)
end

controller:Load(entity: any, key: string) -> (boolean, { [string]: any }?, string?)

Reads, decodes, and optionally deserializes the stored payload.

Exact behavior

  1. Calls:
    DataStore:GetAsync(config.DataStoreName, makeKey(config.KeyPrefix, key))
  2. If the raw value is nil, returns (true, nil, nil).
  3. Runs decodePayload(raw) inside pcall.
  4. If entity.Deserialize exists, calls entity:Deserialize(data).
  5. Returns (true, data, nil) on success.

Failure modes

Error Trigger
DatastoreFail runtime GetAsync threw
DecodeFailed: <detail> malformed JSON / bad payload shape caused decodePayload to throw

Edge cases

  • raw may be either a JSON string or a table. decodePayload accepts both.
  • If decodePayload sees a table, it returns that table directly as data and nil as meta.
  • meta is not forwarded to entity:Deserialize(...).
  • For BaseEntity subclasses, Deserialize writes through __newindex, so loaded values land in Pending + _cache, not immediately into committed Data.

Caution

PersistenceManager.LoadState(...) does not call entity:UpdateEntity() after Deserialize. For mutable BaseEntity instances, loaded values become visible through proxy reads immediately, but they do not produce a version bump or committed Data snapshot until a later UpdateEntity().

local ok, data, err = controller:Load(playerEntity, "Player_123")
if not ok then
    warn("Load failed:", err)
elseif data == nil then
    print("First-time player; using schema defaults")
end

controller:Update(key: string, mutateFn: (data: { [string]: any }) -> { [string]: any }) -> (boolean, string?)

Performs an atomic DataStore update against the envelope's data field.

Exact behavior

EntityPersistence passes a transform into DataStore:UpdateAsync(...) that:

  1. starts with current = {} and meta = {},
  2. if old exists, attempts decodePayload(old) inside pcall,
  3. if decoding succeeds, uses decoded data and meta,
  4. computes nextData = mutateFn(current) or current,
  5. writes encodePayload(nextData, meta) back.

Failure modes

Error Trigger
DatastoreFail runtime UpdateAsync threw or returned nil

Edge cases

  • mutateFn is executed inside Roblox UpdateAsync; it may run multiple times.
  • Returning nil or false from mutateFn does not abort the write; the code falls back to current because of or current.
  • If an existing payload is corrupt, decode failure is swallowed inside Update, and the mutation proceeds from {} + {}.
  • Existing meta is preserved across updates when decoding succeeds.
  • updatedAt is rewritten by encodePayload(...); envelope version stays 1.
local ok, err = controller:Update("Player_123", function(data)
    data.TotalSessions = (data.TotalSessions or 0) + 1
    data.LastSeenAt = os.time()
    return data
end)

controller:Delete(key: string) -> (boolean, string?)

Removes the composed key from the DataStore.

Exact behavior

DataStore:RemoveAsync(config.DataStoreName, makeKey(config.KeyPrefix, key))
  • Returns (false, "DatastoreFail") if RemoveAsync is missing or throws.
  • Returns (true, nil) otherwise.

Edge cases

  • There is no distinction between β€œdeleted existing key” and β€œkey did not exist”.
  • The removed value is ignored.
local ok, err = controller:Delete("Player_123")

🧭 PersistenceManager Wrapper

PersistenceManager.Initialize(Util: any) -> ()

  • Server-only.
  • Initializes EntityPersistence.
  • Stores _core, _settings, and _logger.
  • Does not construct the controller immediately.

Lazy controller resolution

GetController() does this on first use:

local DataStoreSystem = PersistenceManager._core.Factory.Registry.GetEntity("DataStoreSystem")
PersistenceManager.Controller = EntityPersistence.new({
    DataStoreName = Settings.DataStore.DataStoreName,
    KeyPrefix = Settings.DataStore.KeyPrefix,
    DataStoreReference = DataStoreSystem,
})

If DataStoreSystem is missing, the manager logs:

DataStoreSystem not found. Persistence disabled.

and returns nil.

PersistenceManager.LoadState(entity: any, key: string) -> ()

  • Asserts:
    • initialized,
    • server-only,
    • Settings.DataStore.PersistenceEnabled == true.
  • Sets context:
    • _PersistenceKey = key
    • _IsPersistent = true
  • Delegates to controller:Load(entity, key).
  • Logs success or error.

PersistenceManager.SaveState(entity: any) -> ()

  • Same assertions as LoadState.
  • Returns early unless entity._privateProperties.Context._IsPersistent is truthy.
  • Uses _PersistenceKey if present, otherwise Context.EntityId.
  • Delegates to controller:Save(entity, key).

Automatic framework integration

Factory.CreateEntity(...) hooks persistence in two places when params.Persistent is truthy:

  1. On create
    Factory.PersistenceManager.LoadState(entity, params.PersistenceKey or entityId)
  2. On every StateUpdated event
    task.defer(function()
        Factory.PersistenceManager.SaveState(entity)
    end)
  3. On destroy
    Factory.PersistenceManager.SaveState(entity)

πŸ“¦ Envelope Reference

Field Type Written by Notes
version number encodePayload Always 1
updatedAt number encodePayload os.time() at write time
data { [string]: any } entity:Serialize() / Update Persisted entity data
meta { [string]: any } caller metadata / preserved by Update Optional auxiliary data

Key composition

local function makeKey(prefix: string?, key: string): string
    if prefix and prefix ~= "" then
        return prefix .. ":" .. key
    end
    return key
end

Examples:

KeyPrefix key Final DataStore key
"Entity" "Player_123" "Entity:Player_123"
"Door" "FrontGate" "Door:FrontGate"
nil "RawKey" "RawKey"

⚠️ Failure Modes Summary

Surface Failure Result
EntityPersistence.new not initialized / missing config assertion failure
Save no Serialize method (false, "EntityMissingSerialize")
Save JSON encode error (false, "SerializeFailed")
Save / Load / Update / Delete runtime DataStore error (false, "DatastoreFail")
Load bad stored JSON (false, nil, "DecodeFailed: ...")
PersistenceManager.LoadState/SaveState client-side call assertion failure
PersistenceManager.LoadState/SaveState persistence disabled in settings assertion failure
PersistenceManager.GetController DataStoreSystem missing logs warning, no-op

🧩 Edge Cases You Need to Know

  1. Load of a missing key is success, not failure. (true, nil, nil) means β€œnew save”.
  2. Envelope version is not runtime entity version. It never increments beyond 1 in audited source.
  3. meta is preserved by Update, but ignored by Load. entity:Deserialize(...) only receives data.
  4. Update can overwrite corrupt payloads silently. Decode failures inside Update fall back to {} instead of erroring.
  5. Delete cannot confirm the key existed. It only reports whether RemoveAsync threw.
  6. Auto-save happens on every StateUpdated for Persistent entities. If your entity mutates extremely often, you are responsible for controlling update frequency above this layer.

πŸš€ Real Code Examples

Example 1: Manual controller with the real runtime facade

local dataStoreSystem = Orchestrator.GetEntity("DataStoreSystem")
if not dataStoreSystem then
    error("DataStoreSystem not available")
end

local controller = EntityPersistence.new({
    DataStoreName = "EntityPersistence",
    KeyPrefix = "Entity",
    DataStoreReference = dataStoreSystem,
})

local ok, err = controller:Save(playerEntity, "Player_123", {
    SavedAtServer = game.JobId,
})

Example 2: Load then explicitly commit for a mutable BaseEntity

local ok, data, err = controller:Load(playerEntity, "Player_123")
if not ok then
    warn(err)
    return
end

if data and playerEntity.UpdateEntity then
    -- Commit deserialized Pending values into Data/version history.
    playerEntity:UpdateEntity()
end

Example 3: Let the framework auto-load / auto-save

local entity = Orchestrator.CreateEntity({
    EntityClass = "DoorEntity",
    EntityId = "Door_Lobby_01",
    Persistent = true,
    PersistenceKey = "Door_Lobby_01",
    Context = {
        Instance = workspace.Doors.Lobby_01,
        OwnerId = "server",
    },
})

What happens:

  1. Factory.CreateEntity(...) creates the entity.
  2. PersistenceManager.LoadState(...) loads persisted data.
  3. Every later entity:UpdateEntity() triggers StateUpdated.
  4. The factory listener defers PersistenceManager.SaveState(entity).
  5. Destroy also triggers a final save.

Example 4: Offline patch with Update

local playerIds = { "123", "456", "789" }

for _, id in ipairs(playerIds) do
    local ok, err = controller:Update("Player_" .. id, function(data)
        if type(data.Coins) == "number" and data.Coins < 0 then
            data.Coins = 0
        end
        return data
    end)

    if not ok then
        warn("Patch failed for", id, err)
    end
end

πŸ”— See Also

🏠 Home


πŸš€ Getting Started


πŸ—οΈ Architecture


πŸ”§ API Reference


πŸ“š Guides


βš™οΈ Advanced


πŸ› οΈ Development


πŸ“– Reference

Clone this wiki locally