Appearance
SQLite databases
serversideServer scripts can open any number of named SQLite databases and run arbitrary SQL against them. Use this when your data is naturally relational or you need ad-hoc queries: shop tables, leaderboards, audit logs, loot tables, anything you want to JOIN, ORDER BY or aggregate.
If what you actually want is "a keyed set of JSON records that clients see a live, read-only copy of" (inventories, quest logs, item catalogs), reach for replicated collections instead — they are built on the same SQLite engine but handle replication, caching and persistence for you.
| You need… | Use |
|---|---|
| Relational tables, joins, aggregates, server-only data | opendatabase (this page) |
| Per-player or global records that clients mirror live | collection() |
| A handful of small key/value settings | flags |
Opening a database
opendatabase(name) returns a Database handle. The file lives at data/databases/<name>.db and is created lazily on the first query/exec — opening a handle does no I/O at all, so it is fine to do at module top level:
ts
// weapons/leaderboard.ts (server)
const db = opendatabase('leaderboard')Names are 1–64 characters of letters, digits, _ and -. An invalid name throws synchronously from opendatabase, not from a later query.
The handle is just a name plus two methods; opening the same database from several scripts gives handles onto the same file, and db.name tells you which one you have.
query and exec
Both methods are async and take the SQL text plus an optional parameter array:
db.query(sql, params?)resolves with the result rows, as an array ofSqlRowobjects (column name → value).db.exec(sql, params?)resolves with{ rowsAffected, lastInsertRowId }— use it forCREATE,INSERT,UPDATEandDELETE.
ts
const db = opendatabase('leaderboard')
export async function onInitialized() {
await db.exec(`CREATE TABLE IF NOT EXISTS scores (
account TEXT PRIMARY KEY,
best INTEGER NOT NULL DEFAULT 0)`)
}
export async function submitScore(pl: Player, score: number) {
await db.exec(
`INSERT INTO scores (account, best) VALUES (?, ?)
ON CONFLICT(account) DO UPDATE SET best = MAX(best, excluded.best)`,
[pl.account, score])
}
export async function top10() {
const rows = await db.query('SELECT account, best FROM scores ORDER BY best DESC LIMIT 10')
return rows.map(r => ({ account: String(r.account), best: Number(r.best) }))
}A failed statement (syntax error, constraint violation, bad parameter count) rejects the promise with an Error whose message is SQLite's. Wrap awaits in try/catch:
ts
try {
await db.exec('INSERT INTO scores (account, best) VALUES (?, ?)', [name, 10])
} catch (e) {
echo('[leaderboard] insert failed: ' + (e instanceof Error ? e.message : String(e)))
}e is unknown
The script tsconfig enables useUnknownInCatchVariables, so e.message fails the type check — and a type error makes the whole hot reload report compile errors. Use e instanceof Error ? e.message : String(e).
A typical pattern, e.g. for a shop: create the shops / shop_items tables with CREATE TABLE IF NOT EXISTS on load and read them into an in-memory Map.
Parameters
Parameters bind to bare ? placeholders in order. Each one is a SqlParam: number, string, boolean or null. Booleans are stored as SQLite's 1 / 0 (and read back as numbers).
ts
await db.query('SELECT * FROM scores WHERE best > ? AND account != ?', [100, 'Astram'])Always pass user-supplied values (chat text, trigger parameters) as parameters rather than concatenating them into the SQL string.
Under the hood the engine numbers each bare ? as ?1…?N (skipping string literals, quoted identifiers and comments), so a ? inside 'quotes' is text, not a placeholder. An explicit numbered placeholder like ?2 is left as written, which lets you reuse one parameter twice.
Row values
SqlRow values map from SQLite's storage classes:
| SQLite | JavaScript |
|---|---|
INTEGER | number (values beyond 2^53 lose precision) |
REAL | number |
TEXT | string |
NULL | null |
BLOB | { $blob: '<base64>' } |
Rows are plain objects keyed by column name, so duplicate column names collide — SELECT a.id, b.id FROM … gives you one id. Alias them: SELECT a.id AS a_id, b.id AS b_id.
Script queries have no row cap, so add a LIMIT to anything that can grow.
Execution model
All SQL — every script, every database, plus the GRC SQL Explorer — runs on one serialized worker thread. That has a few consequences worth knowing:
- Queries never block the game tick; you
awaitthem like any other promise, and the continuation resumes on the tick thread in your script's context. - Statements run one at a time, in the order they were issued. A write you issued before a read is visible to that read, even across scripts.
- A slow query delays every other script's queries (but not the game loop). Index what you filter on.
- Each connection runs with
journal_mode=WAL,busy_timeout=5000andforeign_keys=ON, soREFERENCESconstraints are enforced.
Hot reload parks pending queries
If a script is unloaded or hot-reloaded while one of its queries is in flight, that promise is never settled — like a cancelled sleep. The SQL itself still runs on the worker; only the continuation is dropped. Make startup work idempotent (CREATE TABLE IF NOT EXISTS, upserts) so a reload can safely repeat it.
Multi-statement SQL and transactions
There is no transaction API on the handle: each query/exec call is atomic on its own, and there is no way to hold a transaction open across awaits. Other scripts' statements can run between two of your calls.
A single call may contain several statements separated by ;. All of them execute, and query returns the last result set that has columns — so INSERT …; SELECT last_insert_rowid() AS id hands you the SELECT.
Parameters only in the first statement
In multi-statement SQL, ? parameters are only reliable in the first statement: SQLite numbers each statement's parameters from 1, but the engine numbers placeholders across the whole string, so a ? in the second statement asks for a parameter the statement doesn't have and the call fails. If that failure happens after a BEGIN, the database's shared connection can be left inside an open transaction. Don't combine BEGIN … COMMIT with parameters — prefer a single statement that does the whole job (INSERT … ON CONFLICT DO UPDATE, UPDATE … WHERE, INSERT … SELECT), or separate awaited calls.
Because every statement is atomic, the usual way to avoid read-modify-write races is to push the arithmetic into SQL:
ts
// Good — one atomic statement
await db.exec('UPDATE wallets SET gold = gold - ? WHERE account = ? AND gold >= ?',
[price, pl.account, price])
.then(r => { if (r.rowsAffected === 0) pl.chat = 'Not enough gold' })
// Racy — another call can run between the SELECT and the UPDATE
const [w] = await db.query('SELECT gold FROM wallets WHERE account = ?', [pl.account])
await db.exec('UPDATE wallets SET gold = ? WHERE account = ?', [Number(w.gold) - price, pl.account])The GRC SQL Explorer
Staff can browse and edit databases from GRC's SQL Explorer window: a sidebar tree of databases, tables and columns, a SQL editor with query tabs, and a results grid. Right-click a table for generated queries (select top rows, count, scripted INSERT/UPDATE/DELETE) and wizards (create table, insert row, add/rename/drop columns, empty/drop table).
Access is gated by two staff rights:
| Right | Allows |
|---|---|
sql | List databases, read schemas and run SQL on databases that already exist |
sqladmin | Create and delete databases |
The Explorer never creates a database implicitly — scripts create them on first use, or staff with sqladmin create them explicitly. SQL is not filtered by statement type, so sql effectively means full read/write access to every database. The results grid shows at most 1000 rows per query (script queries are uncapped).
New rights and saved rights records
Staff accounts that have a saved rights record don't automatically gain rights added to the catalog later. If a staff member can't see the SQL Explorer, grant sql explicitly in the rights editor.
The collections database
Collections with sql backing persist in a database named collections. You can inspect it in the Explorer (or even with opendatabase('collections')), but writing to it bypasses replication: clients won't see the change and the server's in-memory copy won't either. Edit collection records through the collection API or RC's Collections tool.
Deleting a database
Deleting a database from the Explorer closes its connection and removes the .db file (and its -wal / -shm side files). A script that queries it afterwards simply recreates an empty database, so keep your CREATE TABLE IF NOT EXISTS bootstrap on every load.
Related
- Replicated collections — keyed JSON records with live client mirrors, built on the same SQLite worker.
- Execution model — how async continuations and hot reload interact.
- Reference:
opendatabase,Database,SqlRow,SqlParam.