Data Persistence¶
Helix persists ad-hoc state via ix.data (JSON files under data/helix/...) and a timer-driven SaveData hook. Parallax has ax.data for file-based state with a slightly different option shape, and a full ax.database layer for SQL-backed state. The porting decision is usually: "should this value stay as a file, or graduate to a database row?"
Table of Contents¶
- Two Storage Layers
ix.data→ax.dataSignature Mapping- Scope Translation
- The SaveData / LoadData Lifecycle
- When to Use
ax.database - Adding Database Columns
- A Full Worked Example: Door Ownership
- Migration Playbook for Existing State
- Pitfalls
Two Storage Layers¶
| Concern | Helix | Parallax |
|---|---|---|
| Small ad-hoc state | ix.data.Set/Get/Delete |
ax.data:Set/Get/Delete |
| Structured, queryable state | mysql:Insert/Select/Update directly |
ax.database:AddToSchema + mysql query builder |
| Character data | ix.char vars (stored in ix_characters row) |
ax.character vars (stored in ax_characters row) |
| Item data | ix.item instance data field |
ax.item instance data field |
| Periodic save | SaveData hook, 10-minute timer |
Explicit ax.data:Set calls or DB writes |
Helix's SaveData hook was a catch-all: every plugin that needed to persist non-character state would implement it, and the framework timer would flush them all. Parallax doesn't have an equivalent; each subsystem decides when to write, typically at natural state-change boundaries rather than on a timer.
ix.data → ax.data Signature Mapping¶
Helix:
ix.data.Set(key, value, bGlobal, bIgnoreMap)
ix.data.Get(key, default, bGlobal, bIgnoreMap, bRefresh)
ix.data.Delete(key, bGlobal, bIgnoreMap)
Parallax:
ax.data:Set(key, value, { scope = "global"|"project"|"map", human = bool, noCache = bool })
ax.data:Get(key, default, { scope = "global"|"project"|"map", force = bool })
ax.data:Delete(key, { scope = "global"|"project"|"map" })
The key-plus-value arguments are the same. The options flip from positional booleans to a named table.
Scope Translation¶
Helix used two booleans (bGlobal, bIgnoreMap) that together defined where the file lived:
| Helix call | File path | Parallax equivalent |
|---|---|---|
ix.data.Set(k, v) |
data/helix/<schema>/<map>/k.txt |
ax.data:Set(k, v, { scope = "map" }) |
ix.data.Set(k, v, false, true) |
data/helix/<schema>/k.txt |
ax.data:Set(k, v, { scope = "project" }) |
ix.data.Set(k, v, true, true) |
data/helix/k.txt |
ax.data:Set(k, v, { scope = "global" }) |
ix.data.Set(k, v, true, false) |
data/helix/<map>/k.txt |
(no direct equivalent — use project or map) |
The fourth combination — global but map-scoped — had obscure use cases and isn't directly expressible in Parallax. If you have data saved under that shape, decide whether it should be project-scoped or map-scoped and pick one.
Parallax also adds:
- human = true — writes the JSON with indentation and a .json extension, useful for configuration you might hand-edit.
- noCache = true — skips populating the in-memory cache after the write; useful when the caller wants to force a re-read on next access.
- force = true on Get — bypass the cache and re-read from disk; the equivalent of Helix's bRefresh.
The SaveData / LoadData Lifecycle¶
Helix's SaveData and LoadData hooks are a framework-wide ritual. Parallax doesn't have this lifecycle, so port patterns differ by intent.
"Load on boot" pattern¶
Helix:
function PLUGIN:LoadData()
self.lookup = self:GetData() or {}
end
function PLUGIN:SaveData()
self:SetData(self.lookup)
end
Parallax:
function MODULE:OnLoaded()
self.lookup = ax.data:Get("lookup", {}, { scope = "map" })
end
function MODULE:Flush()
ax.data:Set("lookup", self.lookup, { scope = "map" })
end
MODULE:OnLoaded is called once when the module finishes loading. MODULE:Flush is your own method — call it explicitly at state-change boundaries, not on a timer.
"Periodic save" pattern¶
If you really do need timer-driven saves, implement a timer yourself. Put it behind if SERVER then and tie it to the module's lifetime:
function MODULE:OnLoaded()
self.state = ax.data:Get("state", {}, { scope = "map" })
if ( SERVER ) then
timer.Create("axDoorSave", 600, 0, function()
local mod = ax.module:Get(self.uniqueID)
if ( mod ) then mod:Flush() end
end)
end
end
function MODULE:Flush()
ax.data:Set("state", self.state, { scope = "map" })
end
Two things to notice:
- The timer-callback re-resolves the module via ax.module:Get(id) rather than capturing self in a closure. This way, if the module is reloaded, the timer picks up the new instance.
- The 600s interval matches Helix's default. Pick whatever is appropriate for your data's volatility.
"Save on shutdown" pattern¶
Helix's SaveData fires on ShutDown. Parallax doesn't wire up any hook for this specifically; use the standard ShutDown directly:
if ( SERVER ) then
hook.Add("ShutDown", "axMyModuleSave", function()
local mod = ax.module:Get("mymodule")
if ( mod ) then mod:Flush() end
end)
end
When to Use ax.database¶
Reach for ax.database (SQL) instead of ax.data (JSON files) when any of these apply:
- Data has an obvious schema (rows with consistent fields).
- You want to query by something other than the top-level key — filtering, sorting, joining.
- Multiple characters / players / items own distinct slices of the same dataset.
- The dataset grows unbounded (logs, transactions, history).
- You need atomic updates that survive crashes between reads and writes.
Helix plugins that wrote directly to MySQL with mysql:Insert/Select/Update already fit this shape. The port pattern is very close: use the same mysql:* query builder, but register your columns up front through ax.database:AddToSchema so they're created if missing.
Adding Database Columns¶
The type parameter is an ax.type constant, and the database layer translates it to the correct SQL type.
if ( SERVER ) then
ax.database:AddToSchema("ax_door_ownership", "id", ax.type.number)
ax.database:AddToSchema("ax_door_ownership", "map", ax.type.string)
ax.database:AddToSchema("ax_door_ownership", "door_hash", ax.type.number)
ax.database:AddToSchema("ax_door_ownership", "character_id",ax.type.number)
ax.database:AddToSchema("ax_door_ownership", "rent_paid", ax.type.number)
end
The table and columns are created on first use; the framework handles the CREATE TABLE IF NOT EXISTS and ALTER TABLE ADD COLUMN logic.
Then use the mysql query builder to read/write:
local query = mysql:Insert("ax_door_ownership")
query:Insert("map", game.GetMap())
query:Insert("door_hash", door:MapCreationID())
query:Insert("character_id", char:GetID())
query:Callback(function(result, status, lastID)
-- inserted
end)
query:Execute()
This is the same query-builder used by Helix, so existing SQL-savvy code ports without changes except for the table name rename (ix_ prefix → ax_ prefix).
A Full Worked Example: Door Ownership¶
Take a Helix door-ownership plugin that stores per-door lock state in ix.data, and port it to an ax.database implementation.
Helix (plugins/doors.lua, excerpted):
PLUGIN.name = "Door Ownership"
function PLUGIN:LoadData()
self.owners = self:GetData() or {}
end
function PLUGIN:SaveData()
self:SetData(self.owners)
end
function PLUGIN:PlayerUse(client, door)
if ( !door:IsDoor() ) then return end
local ownerID = self.owners[door:MapCreationID()]
if ( ownerID and ownerID != client:GetCharacter():GetID() ) then
client:Notify("This door is owned by someone else.")
return false
end
end
Parallax (modules/doors/boot.lua):
MODULE.name = "Door Ownership"
if ( SERVER ) then
ax.database:AddToSchema("ax_door_ownership", "id", ax.type.number)
ax.database:AddToSchema("ax_door_ownership", "map", ax.type.string)
ax.database:AddToSchema("ax_door_ownership", "door_hash", ax.type.number)
ax.database:AddToSchema("ax_door_ownership", "character_id", ax.type.number)
-- In-memory cache populated on boot
MODULE.cache = MODULE.cache or {}
function MODULE:OnLoaded()
local query = mysql:Select("ax_door_ownership")
query:Select("door_hash")
query:Select("character_id")
query:Where("map", game.GetMap())
query:Callback(function(rows)
self.cache = {}
for _, row in ipairs(rows or {}) do
self.cache[tonumber(row.door_hash)] = tonumber(row.character_id)
end
end)
query:Execute()
end
function MODULE:SetOwner(door, char)
local hash = door:MapCreationID()
self.cache[hash] = char and char:GetID() or nil
if ( char ) then
local q = mysql:Insert("ax_door_ownership")
q:Insert("map", game.GetMap())
q:Insert("door_hash", hash)
q:Insert("character_id", char:GetID())
q:Execute()
else
local q = mysql:Delete("ax_door_ownership")
q:Where("map", game.GetMap())
q:Where("door_hash", hash)
q:Execute()
end
end
end
function MODULE:CanPlayerUseDoor(client, door)
local ownerID = MODULE.cache[door:MapCreationID()]
if ( !ownerID ) then return end -- unowned, allow
local char = client:GetCharacter()
if ( !char or char:GetID() != ownerID ) then
client:Notify("This door is owned by someone else.")
return false
end
end
return MODULE
The significant changes:
PluginSaveData/LoadData→ SQL reads/writes at natural event points.- The plugin's ad-hoc JSON blob is replaced with a typed table keyed by map + door_hash.
- A small in-memory cache (
MODULE.cache) is populated on load; theCanPlayerUseDoorhook reads from the cache (cheap, hot path) and writes go throughSetOwner(rare). PlayerUsereturningfalseto block becomesCanPlayerUseDoorreturningfalse— the correct permission hook in both frameworks.
For a small server with a few doors, keeping everything in a JSON file via ax.data is fine. The database approach scales better for large maps with many owned props and supports queries like "all doors owned by character N" without reading the whole file.
Migration Playbook for Existing State¶
If you have a live Helix server whose data you want to move into Parallax:
- Inventory your state. Grep for
ix.data.Set,PLUGIN:SetData, and rawmysql:calls. List every key and every table. - Classify. For each entry, decide: file or database? Helix plugins that wrote via
PLUGIN:SetDataare usually good candidates forax.data; plugins that wrote via directmysql:calls likely needax.database. - Rename tables. Helix used
ix_*table names; Parallax usesax_*. Write a SQL migration that renames tables (RENAME TABLE ix_characters TO ax_characters;) or duplicates them. - Reconcile columns. Some Helix-specific columns (like inventory width/height on characters) have no Parallax meaning — drop them. Some Parallax-specific columns may need backfilling from defaults.
- Re-serialize JSON blobs. If a stored JSON column changes meaning — e.g. character
data— run a one-shot Lua migration that reads the old, rewrites the new. - Port per-plugin data files. For each
ix.datakey, decide scope and re-save viaax.data:Set. The on-disk location changes fromdata/helix/...todata/parallax/...; you can copy files if the content doesn't need reshaping. - Test with a copy. Always run the migration on a cloned database before touching production.
Pitfalls¶
- Forgetting
scope. The default scope in Parallax is"project"(schema-wide). Helix's default was"map"-scoped. If you port code that assumed map-specific storage, explicitly pass{ scope = "map" }. - Stale in-memory caches. Both frameworks cache reads. If you write to the same key from two servers (e.g. a cluster), cached values diverge. Use
{ force = true }on reads ornoCacheon writes when appropriate. - Missing
AddToSchema. A column you query but never registered causes a SQL error. Declare all columns upfront inOnLoadedor module boot. - Type mismatches.
ax.type.numbermaps to an integer column type. If you need floats or larger-than-int32, verify the actual SQL type your database layer emits. - Hot-reload and timers. If you
timer.CreateinsideOnLoaded, reloading the module stacks duplicate timers. Name your timers after the module ("ax<ModuleName>Save") and the re-create replaces the old one. table.ToJSONvsutil.TableToJSON. Useutil.TableToJSONfor consistency; both frameworks' serializers expect that shape.