Skip to content

Store (tm.store) ​

Context: server and client. Needs tm.db (server), tm.class and tm.callback.

A server-authoritative collection of database-backed records, held in memory. The server keeps every row as a record object, tracks what changed, writes changes to the database in batches, and (optionally) pushes exactly what changed to the players who may see it. The client side is a small mirror that applies those pushes.

Use it for any entity a resource owns and reads far more often than it writes: shops, properties, doors, businesses, placed objects. It is overkill for a table with a handful of rows or data that is only queried now and then.

lua
local shops = tm.store.new({
    name = 'shops', table = 'shops', primaryKey = 'id',

    queries = {                                   -- file paths (read from your resource) or inline SQL
        create    = 'sql/shops/create.sql',       -- optional: runs when the table does not exist
        selectAll = 'sql/shops/select_all.sql',
        insert    = 'sql/shops/insert.sql',
        update    = 'sql/shops/update.sql',
        delete    = 'sql/shops/delete.sql',
    },

    parse     = function(row) return { id = row.id, name = row.name, stock = row.stock, ownerId = row.owner_id, version = row.version } end,
    serialize = function(d) return { id = d.id, name = d.name, stock = d.stock, owner_id = d.ownerId, version = d.version } end,
    runtime   = function(d) return { open = false } end,
    public    = function(record, src) return { id = record.id, name = record.data.name, open = record.state.open, version = record.version } end,

    sync = true,
    callbacks = { getAll = 'tm_shops:getAll', getOne = 'tm_shops:getOne' },
})

shops:get(12):setData('stock', 5)   -- in memory at once, written in the next batch, pushed to subscribers

tm.store.new(config) loads the table when it is called (and creates it first when queries.create is given), so call it at file level or from a thread. It returns a SyncedStore when sync is set and a BaseStore otherwise. Records are plain tables, so a record can be exported or put in an event without dragging the store along.

Config ​

KeyRequiredDescription
nameyesStore name. Part of the default event name.
tableyesDatabase table. Only used for the create-if-missing check.
primaryKeyyesColumn read from each row as the record id.
queries.selectAllyesReturns every row.
queries.createCreates the table when table does not exist.
queries.insert, update, deleteStatements for create, the batched writes and delete. Without update nothing is ever written.
parse(row)yesA database row to the record's data table.
serialize(data)The data table to query parameters. Defaults to a copy of the data.
runtime(data)yesThe initial runtime-only state table.
public(record, src)yesWhat the player src may see. Return nil or false to hide the record from that player.
scope(record)A list of extra player ids that receive pushes for the record.
syncPush changes to clients (creates a SyncedStore).
autoBatchFlush dirty records on a timer. Default true.
batchIntervalFlush delay in ms. Default 5000, minimum 100.
eventNameBase name of the sync and change events. Default tm:store:<name>.
callbacksgetAll and getOne: names of tm.callbacks that return snapshots to clients.
recordClassA class from tm.store.Record.extend(...), see below.
clientFieldsArray of field names applyClientPatch may write.
emitEventsSet false to skip the local dataChanged / stateChanged / created / deleted events. Default true.
publicPerPlayerSee Cost of sync.

Queries ​

A query is a path ending in .sql (read once from the resource that calls tm.store.new) or an inline statement. Parameters are named (@name), supplied by serialize, and the statements are plain MySQL/oxmysql SQL that tm.db runs, so a database adapter can replace it later. update and delete need the primary key parameter (@id).

sql
-- sql/shops/update.sql
UPDATE shops SET name = @name, stock = @stock, owner_id = @owner_id, version = @version WHERE id = @id

Records ​

A record has id, data (persisted, always contains version), state (runtime only, never saved) and version (bumped by every data change). record:getStore() returns the store.

MethodDescription
record:get(key)state[key] if set, otherwise data[key].
record:setData(key, value, skipSync?)Sets a persisted field. Returns true when it changed. Marks the record dirty and bumps the version.
record:setDataMany(values, skipSync?)Several fields as one change: one version bump, one diff.
record:setState(key, value, skipSync?) / setStateManyRuntime fields. Not saved, no version bump.
record:patch(changes)Deep merges nested tables into the data through setDataMany.
record:save()Writes this record now. Returns true when saved.
record:toPublic(src?)Calls the store's public.
record:toTable()A plain copy: { id, version, data, state }.
  • A value that equals the old one (deeply) is not a change. Assign a new table when changing a table value: a table edited in place cannot be told from the old value.
  • id (the primary key) and version are managed by the store. setData refuses them (it returns false and logs a warning).
  • skipSync stores the change but sends nothing to clients.
  • Setting a value to nil removes the field.

Custom record classes ​

lua
local Shop = tm.store.Record.extend({ kind = 'shop' }, function(self, init)
    self.label = ('%s #%s'):format(self.data.name, self.id)   -- data and id are set when this runs
end)
function Shop:restock(n) return self:setData('stock', self.data.stock + n) end

local shops = tm.store.new({ ..., recordClass = Shop })

recordClass must extend tm.store.Record (see Class). Constructors chain, so the base setup always runs first.

Store methods ​

MethodDescription
store:get(id)The record, or nil. A numeric string also finds a numeric id.
store:getAll()All records keyed by id. This is the live table: read it, do not modify it.
store:create(data)Inserts a row and returns the new record, or nil, reason. data is in your record format (the one serialize reads), not a database row. The key from the insert (or your own, for a table without auto increment) is set on it.
store:delete(id)Deletes the row and the record. Returns true, or false (and a reason when the database refused).
store:serializeOne(id, src?) / serializeAll(src?)The public view of one or all records for a player. Hidden records are left out.
store:flushDirty()Writes every dirty record now. Returns how many were saved.
store:applyClientPatch(id, changes)Applies a client's change request through setDataMany. Only clientFields are written when that list exists, and the key and version never are. Validate the player and the values first.
store:setBatchInterval(ms) / setAutoBatch(bool)Change the flush behavior at runtime (for example from a setting).
store:destroy()Writes what is dirty and stops the store. Runs by itself when the resource stops.

tm.store.Record, tm.store.BaseStore and tm.store.SyncedStore are exported for recordClass, instanceOf checks and the type system.

Dirty tracking and batched writes ​

  • A data change queues the record once and starts a single timer (batchInterval). An idle store has no timer and no thread.
  • A flush with one dirty record is one UPDATE. With several, they run as one transaction per 100 records. If a transaction fails, the records are retried one by one so a single bad row cannot block the others.
  • A record is only marked clean when nothing changed while it was being written. A failed write leaves the record dirty and queued again. Two flushes never write the same record at the same time.
  • The same flush runs when the resource stops, on a txAdmin shutdown announcement, and from store:destroy().
  • Query files are read once and cached.

Events ​

Local server events (TriggerEvent, arguments are copied like any event payload). Base name is eventName, default tm:store:<name>:

EventArguments
<eventName>:dataChangedid, key, oldValue, newValue, record (once per changed key, after the change is applied)
<eventName>:stateChangedid, key, oldValue, newValue, record
<eventName>:createdid, record
<eventName>:deletedid, record
lua
AddEventHandler('tm:store:shops:dataChanged', function(id, key, old, new) end)

Sync ​

With sync = true the store is a SyncedStore. Nothing is sent until players subscribe (or the scope function names them).

MethodDescription
store:subscribe(source)Global subscriber: receives every record it may see.
store:subscribeTo(source, id)Subscribes to one record. false when the record does not exist.
store:unsubscribeFrom(source, id)Leaves one record.
store:unsubscribe(source) / unsubscribeAll(source)Leaves everything (global and per record).
store:isSubscribed(source, id?)true for a global subscriber, or (with id) a subscriber of that record.
store:getSubscriptions(id?)Subscriber ids: those of the record, or the global ones.

A player's subscriptions are removed when they drop. A deleted record's subscriptions end with it. When the resource stops, the store forgets all of them.

The store opens no client-facing events of its own. Wire the ones the client store is configured with, and validate:

lua
RegisterNetEvent('tm_shops:subscribe', function() shops:subscribe(source) end)
RegisterNetEvent('tm_shops:subscribeTo', function(id)
    if type(id) == 'number' then shops:subscribeTo(source, id) end
end)

Who receives what ​

The audience of a record is its global subscribers, its record subscribers and the players returned by scope(record) (entries that are not online are skipped). What each of them sees is decided by public(record, src):

  • A player for whom public returns nil or false does not see the record: no snapshot entry, no pushes.
  • After every change the store compares each audience member's view before and after, and sends the difference: a create when the record became visible to them (or they entered the scope), a delete when it became hidden (or they left the scope), otherwise a diff of the changed keys. Fields that are not part of public never leave the server, and a change that does not alter a player's view sends them nothing.
  • scope limits pushes, public limits content. Without subscribers and without scope, nobody is sent anything (a plain store with sync = true does not broadcast to everyone).

Client events, sent to individual players:

EventArguments
<eventName>:createid, view
<eventName>:deleteid
<eventName>:updateDataid, changed, removed (changed top level keys with their new values, and an array of keys that disappeared)
<eventName>:updateStateid, changed, removed

Put version = record.version in public and the client ignores a data diff older than what it holds.

Cost of sync ​

  • No audience means no work: no public calls, no packets.
  • Per change, public runs twice (before and after) for each distinct audience view. A public written with one parameter (function(record)) cannot depend on the player, so it runs once per side however many players listen. If your single-parameter function reads the player some other way, set publicPerPlayer = true.
  • Only changed keys are sent, once per player. Use setDataMany / setStateMany to send several fields as one packet.

Client mirror ​

lua
local shops = tm.store.new({
    eventName = 'tm:store:shops',                   -- must equal the server store's eventName
    getAllCallback = 'tm_shops:getAll', getOneCallback = 'tm_shops:getOne',
    subscribeAllEvent = 'tm_shops:subscribe', subscribeEvent = 'tm_shops:subscribeTo',
    unsubscribeAllEvent = 'tm_shops:unsubscribe', unsubscribeEvent = 'tm_shops:unsubscribeFrom',
})

CreateThread(function()
    shops:subscribe()   -- start the pushes first,
    shops:loadAll()     -- then take the snapshot
end)
MethodDescription
loadAll()Replaces the mirror with the server snapshot (blocks, use a thread). Mirrored records newer than the snapshot entry are kept.
loadOne(id)Fetches one record. If the server no longer shows it to this player, it is removed from the mirror.
get(id) / getAll()Read the mirror (the live table).
setOptimistic(id, changes)Merges a guess into the mirrored record at once. The next server message for the record wins.
subscribe() / unsubscribe() / subscribeTo(id) / unsubscribeFrom(id)Fire the configured server events. They return false when the event is not configured.

The mirror holds exactly the server's public view. Updates to a record the mirror does not hold are ignored; load it first. Treat the mirror as read-only except setOptimistic. For a single handle that also sends writes to the server, see Store proxy.

Security ​

  • The client never decides what is written. applyClientPatch and the events you wire are the only way in: check the player, the record and every value.
  • Snapshot callbacks have no built-in rate limit. They return a table of every record the player may see, so keep public lean.
  • Never put secrets in public. Use nil to hide whole records from players who must not see them.