MAEngine

Database

SurrealDB-backed persistent storage with transparent caching and real-time sync

Database

Availability: Server Only

MAEngine uses SurrealDB for persistent storage with a comprehensive Lua API. The DB table provides a synchronous-first API with transparent caching, automatic batching, and real-time synchronization via live queries.


Architecture Overview


Caching System

The cache is transparent - you don't need to manage it manually. Reads check cache first, writes update cache immediately, and live queries keep it synchronized with external changes.

How Caching Works

OperationCache Behavior
DB.Get()Returns from cache instantly if present; fetches from DB on miss and caches result
DB.Set()Updates cache immediately, then blocks until DB confirms
DB.Create()Blocks until DB returns new ID, then caches with ID
DB.Delete()Removes from cache immediately, then blocks until DB confirms
DB.Find()Fetches from DB, caches all returned records
DB.Preload()Loads entire table into cache at startup

Cache Structure

Cache (HashMap<String, HashMap<String, JsonValue>>)
├── "players"
│   ├── "player:abc123" → { name: "Alice", score: 100 }
│   └── "player:def456" → { name: "Bob", score: 75 }
├── "items"
│   ├── "item:sword01" → { name: "Iron Sword", damage: 25 }
│   └── "item:shield01" → { name: "Wooden Shield", defense: 10 }
└── "inventories"
    └── ...

Preloading for Instant Access

Call DB.Preload() during server start to warm the cache:

Server.Subscribe("Start", function()
    local player_count = DB.Preload("players")
    local item_count = DB.Preload("items")
    Log("Preloaded " .. player_count .. " players, " .. item_count .. " items")
end)

Only preload tables you'll access frequently. Large tables consume memory and increase startup time.


Live Query Synchronization

When you access a table, MAEngine automatically subscribes to SurrealDB's live queries. This means:

  1. External changes are synced - If another server instance or admin tool modifies data, your cache updates automatically
  2. No stale data - The cache stays consistent with the database
  3. Zero configuration - Happens transparently on first table access

API Reference

Synchronous API (Default)

Use these for most operations. They use cache for instant reads and block only when necessary.


Asynchronous API

Use when you explicitly want non-blocking behavior. Perfect for operations during player disconnects.


Write Batching System

Fire-and-forget operations (DB.SetAsync without callback) are automatically batched for optimal performance.

Manual Batch Control

-- Flush all pending writes immediately
local count = DB.FlushWrites()
Log("Flushed " .. count .. " pending writes")

-- Check pending write count
local pending = DB.PendingWriteCount()
Log("Pending writes: " .. pending)

Batching is automatic. Use DB.FlushWrites() only when you need immediate persistence (e.g., before server shutdown).

Handling Write Failures

Fire-and-forget writes can fail silently (network issues, schema violations, etc.). Register a callback to handle failures:

-- Subscribe to write failures
local unsubscribe = DB.OnWriteFailure(function(failure)
    Warn("DB write failed: " .. failure.operation .. " on " .. failure.table .. ":" .. failure.id)
    Warn("Error: " .. failure.error)

    -- failure.data contains the original data (for set/update operations)
    if failure.data then
        -- Retry, log, or handle as needed
        DB.SetAsync(failure.table, failure.id, failure.data)  -- Retry
    end
end)

-- Later, if needed:
unsubscribe()

Prop

Type

Write failures are rare but can happen during DB connection issues or schema violations. For critical data, use synchronous writes (DB.Set) which return success/failure immediately.


Graph Relations

SurrealDB supports graph-style relations between records. Use these for complex data relationships.


Transactions

Execute multiple operations atomically. If any operation fails, all are rolled back.

local results = DB.Begin()
    :Update("players", "1", { gold = "gold - 100" })
    :Update("players", "2", { gold = "gold + 100" })
    :Create("trade_log", {
        from = "player:1",
        to = "player:2",
        amount = 100,
        timestamp = GetUnixTime()
    })
    :Commit()

Transaction Methods

MethodDescription
:Create(table, data)Add a new record
:Update(table, id, data)Merge with existing (supports SurrealQL expressions)
:Set(table, id, data)Replace entire record
:Merge(table, id, data)Deep merge with existing
:Delete(table, id)Delete a record
:Relate(from, rel, to, data?)Create a relation
:Commit()Execute all operations atomically

Transaction :Update() supports SurrealQL expressions like "gold - 100" for atomic arithmetic operations.


Live Subscriptions

Subscribe to real-time database changes. Callbacks fire when records are created, updated, or deleted by external sources (other server instances, admin tools).

local sub_id = DB.Subscribe("players", "Create", function(record)
    Log("New player joined: " .. record.name)
end)

DB.Subscribe("players", "Update", function(record)
    Log("Player updated: " .. record.name)
end)

DB.Subscribe("players", "Delete", function(record)
    Log("Player left: " .. record.id)
end)

-- Unsubscribe when done
DB.Unsubscribe(sub_id)

Local Write Filtering

By default, subscriptions ignore your own writes - only external changes trigger callbacks. This prevents infinite loops and unnecessary processing.

-- Default: only fires for external changes
DB.Subscribe("players", "Update", function(record)
    Log("External update to: " .. record.id)
end)

-- Opt-in: also fires for your own writes
DB.Subscribe("players", "Update", function(record)
    Log("Any update to: " .. record.id)
end, true)  -- includeLocal = true

Prop

Type

Use subscriptions for reacting to external changes (multi-server sync, admin tools). For your own writes, you already know when they happen - just handle it in the same code that makes the write.


Schema Definition

Define table structure for validation, default values, and indexing.


Utility Methods

MethodReturnsDescription
DB.Count(table, filter?)integerCount matching records
DB.Exists(table, id)booleanCheck if record exists
DB.DeleteAll(table)integerDelete all records, return count
DB.Info()tableGet database information
DB.FlushWrites()integerFlush pending async writes
DB.PendingWriteCount()integerGet pending write count
DB.OnWriteFailure(callback)functionSubscribe to write failures, returns unsubscribe fn

Type Preservation

MAEngine automatically preserves Vec3 and Quat types when storing to the database:

-- Storing Vec3
DB.Set("waypoints", "spawn", {
    position = Vec3(100, 200, 50),
    rotation = Quat(0, 0, 0, 1)
})

-- Retrieved as proper types
local waypoint = DB.Get("waypoints", "spawn")
local pos = waypoint.position  -- Returns Vec3 userdata
Log("X: " .. pos.x)            -- Works!

Internally stored as:

{
    "position": { "x": 100, "y": 200, "z": 50, "__type": "Vec3" },
    "rotation": { "x": 0, "y": 0, "z": 0, "w": 1, "__type": "Quat" }
}

Best Practices

Preload at Startup

Load frequently accessed tables into cache during server start:

Server.Subscribe("Start", function()
    DB.Preload("players")
    DB.Preload("items")
    DB.Preload("config")
end)

Use Fire-and-Forget for Disconnects

When players disconnect, use async without callback for optimal batching:

Player.Subscribe("Destroy", function(player)
    -- Fire-and-forget: updates cache, batches DB write
    DB.SetAsync("players", tostring(player:GetId()), {
        score = player:GetValue("score"),
        last_seen = GetUnixTime()
    })
end)

Use Transactions for Atomic Operations

When multiple records must change together:

-- Trading: both updates succeed or both fail
DB.Begin()
    :Update("players", seller_id, { gold = "gold + " .. price })
    :Update("players", buyer_id, { gold = "gold - " .. price })
    :Update("items", item_id, { owner = buyer_id })
    :Commit()

Index Frequently Queried Fields

Server.Subscribe("Start", function()
    DB.DefineIndex("players", "idx_score", { "score" })
    DB.DefineIndex("items", "idx_owner", { "owner_id" })
end)

Complete Example: Player Inventory System

-- Schema setup
Server.Subscribe("Start", function()
    DB.DefineTable("inventories", "schemafull")
    DB.DefineField("inventories", "player_id", "string")
    DB.DefineField("inventories", "items", "array", { default = "[]" })
    DB.DefineField("inventories", "gold", "int", { default = 0 })
    DB.DefineIndex("inventories", "idx_player", { "player_id" }, "unique")

    DB.Preload("inventories")
    Log("Inventory system ready")
end)

-- Load inventory
function LoadInventory(player)
    local id = tostring(player:GetId())
    local inv = DB.FindOne("inventories", { player_id = id })

    if not inv then
        -- Create new inventory
        local inv_id = DB.Create("inventories", {
            player_id = id,
            items = {},
            gold = 100  -- Starting gold
        })
        inv = DB.Get("inventories", inv_id)
    end

    return inv
end

-- Save inventory (non-blocking)
function SaveInventory(player, inventory)
    DB.SetAsync("inventories", inventory.id, inventory)
end

-- Add item with validation
function AddItem(player, item_id)
    local inv = player:GetPrivateValue("inventory")
    if #inv.items >= 50 then
        return false, "Inventory full"
    end

    table.insert(inv.items, item_id)
    player:SetPrivateValue("inventory", inv)
    SaveInventory(player, inv)
    return true
end

-- Player spawn
Player.Subscribe("Spawn", function(player)
    local inv = LoadInventory(player)
    player:SetPrivateValue("inventory", inv)
end)

-- Player disconnect (fire-and-forget save)
Player.Subscribe("Destroy", function(player)
    local inv = player:GetPrivateValue("inventory")
    if inv then
        SaveInventory(player, inv)
    end
end)

Consistency Contract

Understanding the exact behavior of each method prevents common bugs:

MethodCache BehaviorBlockingOn Failure
DB.GetCache-first, DB on missYesReturns nil
DB.SetOptimistic updateYesRollback cache, returns false
DB.UpdateOptimistic mergeYesRollback cache, returns false
DB.DeleteOptimistic removeYesRollback cache, returns false
DB.CreateCache on successYesReturns nil
DB.SetAsync (no callback)Optimistic updateNoFires DB.OnWriteFailure callback
DB.QueryBypasses cacheYesReturns empty array
DB.FindCaches resultsYesReturns empty array

Optimistic + Rollback: Synchronous writes update the cache immediately for instant reads. If the database rejects the write (constraint violation, connection issue), the cache is rolled back to its previous state. This ensures cache never gets out of sync with the database.

Live Query Sync: Tables you access are automatically subscribed to live queries. External changes (from other server instances or admin tools) update your cache in real-time.

On this page