Lyra Player Data Management

repository·main·Indexed 40 days ago

https://github.com/paradoxum-games/lyra

A player data management library for Roblox designed for handling large datasets with safety and robustness. Lyra v0.6.0 provides features including atomic transactions, session locking, schema validation, auto-sharding, and a migration system to prevent data corruption and loss.

Tokens
9.1K
Snippets
27
Records
31
Agent score
98%

What's inside Lyra

  1. How Lyra migrations work

    main

    Lyra's migration system automatically transforms player data when the data format changes (e.g., adding fields, renaming, or restructuring).

    When player data is loaded, Lyra follows this lifecycle:

    1. Checks which migrations have already run for that specific player.
    2. Runs any new migrations in the order they are defined.
    3. Records which migrations were applied to prevent re-running them.
    4. Validates the final resulting data against your defined schema.

    Each migration runs exactly once per player, ensuring consistency across different sessions or servers.

  2. How to use Lyra as the source of truth for data

    main

    Lyra is designed to be the primary source of truth for your data to ensure atomicity, transaction integrity, and schema validation.

    Recommended Pattern: Always perform updates directly on the Lyra store first. Other game systems should then derive their state from Lyra using changedCallbacks rather than attempting to manage the same data in an external state manager.

    Why this matters:

    • Atomicity: Lyra updates either succeed completely or fail entirely. If you update external state first, you lose this guarantee.
    • Transactions: Lyra's ability to update multiple players' data atomically requires it to control the data flow.
    • Schema Validation: Lyra validates data against your schema during updates. Bypassing Lyra by updating external state first can lead to data corruption if the subsequent Lyra update fails.
    • Consistency: Using Lyra as the source of truth prevents divergence between your local game state and the persistent data.
    -- RECOMMENDED: Lyra is the primary source of truth
    function giveCoins(player, amount)
        -- Update Lyra first
        store:updateAsync(player.UserId, function(data)
            data.coins += amount
            return true
        end)
        
        -- Other systems can derive from Lyra through changedCallbacks
    end
  3. How sessions and session locking work

    main

    A session is the mechanism Lyra uses to manage the connection between a player and their saved data.

    To prevent race conditions and data loss across multiple servers, Lyra uses session locking. When you load a player's data, Lyra 'locks' it, granting your server exclusive access. You must establish a session before attempting to read or modify any data.

    Lifecycle Pattern:

    1. Load: Use store:loadAsync(player) when a player joins to establish exclusive access.
    2. Unload: Use store:unloadAsync(player) when a player leaves to release the lock and save pending changes.
    Players.PlayerAdded:Connect(function(player)
        -- Establish exclusive access to the player's data
        store:loadAsync(player)
    end)
    
    Players.PlayerRemoving:Connect(function(player)
        -- Release the lock and save any pending changes
        store:unloadAsync(player)
    end)
  4. Data safety in change callbacks

    main

    Data passed to changedCallbacks is frozen. Attempting to modify newData or oldData within a callback will result in an error. This ensures that callbacks remain pure and do not accidentally mutate the source of truth in the player store.

    local function callback(key, newData, oldData)
        -- ❌ This will error - data is frozen
        newData.coins = 100
    end
  5. Avoid stale data when updating player data

    main

    When modifying data, do not rely on variables captured from a previous :getAsync() call. Because data can change between the time you fetch it and the time you attempt to update it, you must perform all conditional logic inside the updateAsync callback to ensure the check is performed against the most recent state.

    -- 🚫 Don't do this:
    local oldData = store:getAsync(player)
    store:updateAsync(player, function(newData)
        if not oldData.claimedDailyReward then -- This data might be stale!
            return false
        end
        newData.coins += 500
        newData.claimedDailyReward = true
        return true
    end)
    
    -- ✅ Do this instead:
    store:updateAsync(player, function(data)
        if not data.claimedDailyReward then -- This data is always current
            return false
        end
        data.coins += 500
        data.claimedDailyReward = true
        return true
    end)
  6. Import existing player data into Lyra

    main

    To migrate from an existing data system to Lyra, use the importLegacyData option when calling Lyra.createPlayerStore. This function is called for players who do not have Lyra data yet.

    • If the function returns data, Lyra will use that data for the player.
    • If the function returns nil, Lyra will use the provided template.
    • If the function throws an error, Lyra will kick the player and prompt them to rejoin to retry the import.
    local store = Lyra.createPlayerStore({
        name = "PlayerData",
        template = template,
        schema = schema,
        importLegacyData = function(key)
            local success, data = pcall(function()
                return YourCurrentSystem.getData(key)
            end)
            
            if not success then
                -- If there's an error, Lyra will kick the player and prompt them
                -- to rejoin to try again.
                error("Failed to reach data system")
            end
    
            if data ~= nil then
                return data -- Return existing data to import
            end
            
            return nil -- Return nil for new players to use template
        end,
    })
  7. Quick Start with Lyra

    main

    Lyra provides a structured way to manage player data using stores. You define a store with a name, a template for new players, and a schema for validation.

    Key workflows include:

    • Loading: Use store:loadAsync(player) when a player joins.
    • Updating: Use store:updateAsync(player, callback) for safe, validated modifications. The callback receives the current data and should return true to commit changes or false to abort.
    • Transactions: Use store:txAsync({player1, player2, ...}, callback) to perform atomic operations across multiple players (e.g., trading). Either all changes in the transaction succeed, or none do.

    Critical Rule: Never use data from a previous :getAsync() call to make logic decisions inside an update. Always perform checks inside the updateAsync callback using the provided data argument to ensure you are working with the most current state and avoid stale data bugs.

    local store = Lyra.createPlayerStore({
        name = "PlayerData",
        template = {
            coins = 0,
            inventory = {},
        },
        schema = t.strictInterface({
            coins = t.number,
            inventory = t.table,
        }),
    })
    
    -- Load data when players join
    Players.PlayerAdded:Connect(function(player)
        store:loadAsync(player)
    end)
    
    -- Safe updates with validation
    store:updateAsync(player, function(data)
        if data.coins < itemPrice then
            return false -- Abort if can't afford
        end
        data.coins -= itemPrice
        table.insert(data.inventory, itemId)
        return true
    end)
    
    -- Atomic trades between players
    store:txAsync({player1, player2}, function(state)
        -- Either both changes happen or neither does
        state[player1].coins -= 100
        state[player2].coins += 100
        return true
    end)
  8. Ensure data persistence on server shutdown

    main

    Lyra performs automatic autosaves every 5 minutes for all active sessions. However, to ensure all data is properly persisted when the server shuts down, you must manually call store:closeAsync() within a BindToClose function.

    game:BindToClose(function()
        store:closeAsync()
    end)
  9. Capture Lyra logs using logCallback

    main

    Lyra provides detailed logging to help diagnose issues and understand data operations. You can intercept all log messages by providing a logCallback function in the configuration object when calling Lyra.createPlayerStore.

    Each log message passed to the callback is an object containing:

    • level: A string representing the severity.
    • message: A descriptive string of the event.
    • context (optional): An object containing additional metadata such as session IDs, keys being operated on, or timing data.
    local function handleLogs(message)
        print(`[Lyra][{string.upper(message.level)}] {message.message}`)
        
        if message.context then
            -- Context contains relevant data like keys, session info, etc.
            print("Context:", message.context)
        end
    end
    
    local store = Lyra.createPlayerStore({
        name = "PlayerData",
        template = template,
        schema = schema,
        logCallback = handleLogs,
    })
  10. Add new fields to existing player data

    main

    To add a new field to your player data without breaking existing players, you must perform three steps:

    1. Update the template: Add the field with its default value to your template object.
    2. Update the schema: Add the field to your schema to define its type and validation.
    3. Create a migration step: Use Lyra.MigrationStep.addFields to inject the field into existing player data.

    Example:

    -- Step 1: Add to template
    local template = {
        coins = 0,  -- Existing field
        gems = 0,   -- New field
    }
    
    -- Step 2: Add to schema
    local schema = {
        coins = t.number,
        gems = t.number,  -- New field
    }
    
    -- Step 3: Add migration
    local store = Lyra.createPlayerStore({
        template = template,
        schema = schema,
        migrationSteps = {
            Lyra.MigrationStep.addFields("addGems", {
                gems = 0,
            }),
        },
    })
  11. Integrate Lyra with external state management libraries

    main

    If you use state management libraries like Reflex, Charm, or custom solutions, follow these two strategies to maintain data integrity:

    1. Separate Ephemeral and Persistent State

    Use your external state management library for ephemeral game state (data that doesn't need to persist across sessions) and use Lyra for persistent player data.

    -- Lyra handles persistent data
    store:updateAsync(player.UserId, function(data)
        data.questProgress.questId = 5
        data.questProgress.stepsCompleted += 1
        return true
    end)
    
    -- Game systems can track active/ephemeral state separately
    gameState.activeQuests[player.UserId] = {
        questId = 5,
        timeRemaining = 600,
        location = workspace.QuestLocations.Cave
    }

    2. Derive State from Lyra using changedCallbacks

    Instead of pushing data from your external state to Lyra, let Lyra drive your external state by reacting to data changes via callbacks.

    -- When Lyra data changes, update external state
    changedCallbacks = {
        function(key, newData, oldData)
            local playerId = tonumber(key)
            gameState.players[playerId][key] = newData[key]
        end
    }
  12. Quick Start: Managing Player Data with Lyra

    main

    Lyra provides a high-level API for creating player data stores, loading/unloading data during player lifecycle events, and performing atomic updates.

    Key workflow steps:

    1. Create a Store: Use Lyra.createPlayerStore with a name, a template (default values), and a schema (for validation).
    2. Lifecycle Management: Call store:loadAsync(player) when a player joins and store:unloadAsync(player) when they leave.
    3. Data Updates: Use store:updateAsync(player, callback) to modify data safely. The callback must return true to commit changes.
    4. Atomic Transactions: Use store:txAsync({players}, callback) to perform operations involving multiple players (e.g., trading) to prevent duplication bugs.
    local store = Lyra.createPlayerStore({
        name = "PlayerData",
        template = {
            coins = 0,
            inventory = {},
        },
        schema = t.strictInterface({
            coins = t.number,
            inventory = t.table,
        }),
    })
    
    -- Load data when players join
    Players.PlayerAdded:Connect(function(player)
        store:loadAsync(player)
    end)
    
    -- Free up resources when players leave
    Players.PlayerRemoving:Connect(function(player)
        store:unloadAsync(player)
    end)
    
    -- Update data
    store:updateAsync(player, function(data)
        data.coins += 100
        return true
    end)
    
    -- Atomic transactions
    store:txAsync({player1, player2}, function(state)
        local amount = 50
        state[player1].coins -= amount
        state[player2].coins += amount
        return true
    end)