Skip to content

Storing data ​

A resource that declares [storage] gets its own database, dlmp.db, which the server keeps in its data/ folder across restarts. The accounts resource used on this page keeps one account per launcher installation; its code is covered by the server's tests.

Declare the storage ​

The manifest lists the migrations that create and update the schema:

toml
name = "accounts"
type = "library"

[server]
scripts = ["server.lua"]
exports = ["account_id"]

[storage.migrations]
sqlite = ["migrations/sqlite/001_accounts.sql"]

Each migration runs once, in order, before the resource's scripts. Never edit a migration that has already run on a server; add a new one instead.

sql
-- One account per device credential. AUTOINCREMENT: the id of a deleted account is never handed out again, so data other
-- resources keep by account id cannot pass to someone else.
CREATE TABLE accounts (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    credential TEXT NOT NULL UNIQUE,
    name TEXT NOT NULL,
    sign_ins INTEGER NOT NULL DEFAULT 1,
    created_at INTEGER NOT NULL DEFAULT (unixepoch()),
    last_seen_at INTEGER NOT NULL DEFAULT (unixepoch())
);

Query it ​

Every call queues the statement and returns immediately; the result is delivered to a callback in a later server step. The accounts sign-in waits for its query to finish before it admits the player:

lua
-- Device-only accounts: the credential the launcher proves for this server is the account, so a player gets the same
-- account back on every visit from the same launcher, with no password. Make it the server's auth resource with
-- `[auth] resource = "accounts"` in server.toml (and list it in `[resources] startup`). Other resources key their data by
-- `dlmp.import("accounts").account_id(player:id())`; a server with logins of its own replaces this resource.

local NO_CREDENTIAL = "Join through the DarqlineMP launcher to keep a profile here." -- at most 64 bytes
local SIGN_IN_FAILED = "Sign-in failed, try again later."

-- A returning device finds its account by the update; only a new one costs the second statement. Not an upsert: that
-- spends an AUTOINCREMENT id on every sign-in. NEW creates nothing when a parallel sign-in of the same device already
-- has, and that sign-in's account is then found by the update again.
local RETURNING = [[
UPDATE accounts SET name = ?, sign_ins = sign_ins + 1, last_seen_at = unixepoch() WHERE credential = ?
RETURNING id, sign_ins
]]
local NEW = [[
INSERT INTO accounts (credential, name) SELECT ?, ? WHERE NOT EXISTS (SELECT 1 FROM accounts WHERE credential = ?)
RETURNING id, sign_ins
]]

local account_of = {} -- player id -> account id, from the accept to the disconnect

-- Lets `pending` in as the account in `rows`, or turns it away when the storage failed or found none.
local function admit(pending, name, rows, err)
    local account = rows and rows[1]
    if not account then
        print(("accounts: sign-in of %s failed: %s"):format(name, err or "no account"))
        pending:reject(SIGN_IN_FAILED)
        return
    end
    if not pending:accept() then
        return -- left or timed out while the storage answered
    end
    account_of[pending:id()] = account.id
    local kind = account.sign_ins == 1 and "new" or "returning"
    print(("accounts: %s is account %d (%s)"):format(name, account.id, kind))
end

-- Queues `sql`; a full queue turns the connection away at once.
local function ask(pending, name, sql, params, callback)
    local queued, err = dlmp.db.query(sql, params, callback)
    if not queued then
        print(("accounts: sign-in of %s not queued: %s"):format(name, err))
        pending:reject(SIGN_IN_FAILED)
    end
end

dlmp.add_event_handler("player_auth", dlmp.root, function(pending)
    local credential = pending:credential()
    if not credential then
        pending:reject(NO_CREDENTIAL)
        return
    end
    local name = pending:name()
    local function load(callback)
        ask(pending, name, RETURNING, { name, credential }, callback)
    end
    load(function(rows, err)
        if not rows or #rows > 0 then
            admit(pending, name, rows, err)
            return
        end
        ask(pending, name, NEW, { credential, name, credential }, function(created, create_err)
            if created and #created == 0 then
                load(function(again, again_err)
                    admit(pending, name, again, again_err)
                end)
            else
                admit(pending, name, created, create_err)
            end
        end)
    end)
end)

dlmp.add_event_handler("player_disconnect", dlmp.root, function(player)
    account_of[player:id()] = nil
end)

--- The account id of the connected player `player_id`, or nil before its sign-in finished.
---@param player_id integer
---@return integer?
function account_id(player_id)
    return account_of[player_id]
end
  • dlmp.db.query passes the rows to its callback; exec and transaction modify rows. Pass values as ? parameters; never concatenate them into the SQL.
  • No Lua code runs between the statements of a single call, so put any condition that must hold at write time into the SQL (WHERE money >= ?) rather than checking it in Lua first.
  • account_id is an export: a resource that lists accounts in its dependencies calls it as dlmp.import("accounts").account_id(player:id()).

The request queue, limits and storage lifecycle are described in Resources.

Documentation licensed under CC BY 4.0. DarqlineMP is not affiliated with Rockstar Games or Take-Two Interactive. Grand Theft Auto is a trademark of Take-Two Interactive Software, Inc.