Files
healer-man/server/database.mjs
T
2026-08-20 14:05:50 -04:00

583 lines
26 KiB
JavaScript

import { createHash, randomBytes, randomUUID, scrypt as scryptCallback, timingSafeEqual } from "node:crypto";
import { mkdirSync } from "node:fs";
import path from "node:path";
import { promisify } from "node:util";
import { DatabaseSync } from "node:sqlite";
import {
cloneDefaultManastormAdminConfig,
normalizeManastormAdminConfig,
} from "./manastorm-config.mjs";
const scrypt = promisify(scryptCallback);
export class GameDatabaseError extends Error {
constructor(message, status = 400, code = "bad_request") {
super(message);
this.name = "GameDatabaseError";
this.status = status;
this.code = code;
}
}
function validateCredentials(username, password) {
if (!/^[A-Za-z0-9_]{3,20}$/.test(username)) {
throw new GameDatabaseError("Username must be 3-20 letters, numbers, or underscores.");
}
if (typeof password !== "string" || password.length < 8) {
throw new GameDatabaseError("Password must be at least 8 characters.");
}
if (password.length > 128) throw new GameDatabaseError("Password must be 128 characters or fewer.");
}
function tokenHash(token) {
return createHash("sha256").update(token).digest("hex");
}
async function derivePassword(password, salt) {
return Buffer.from(await scrypt(password, salt, 64));
}
const GM_USERNAME_KEYS = new Set(["phenom"]);
function accountRoles(username) {
return GM_USERNAME_KEYS.has(String(username ?? "").trim().toLowerCase()) ? ["gm"] : [];
}
function publicAccount(row) {
return {
id: row.id,
username: row.username,
createdAt: Number(row.created_at ?? row.createdAt),
roles: accountRoles(row.username),
};
}
export function openGameDatabase(options = {}) {
const dataDir = path.resolve(options.dataDir ?? process.env.DATA_DIR ?? "data/runtime");
const databasePath = path.resolve(options.databasePath ?? path.join(dataDir, "game.db"));
const sessionTtlDays = Math.max(1, Number(options.sessionTtlDays ?? process.env.SESSION_TTL_DAYS ?? 30));
mkdirSync(path.dirname(databasePath), { recursive: true });
const database = new DatabaseSync(databasePath);
database.exec("PRAGMA journal_mode = WAL");
database.exec("PRAGMA foreign_keys = ON");
database.exec("PRAGMA busy_timeout = 5000");
database.exec(`
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY, username TEXT NOT NULL, username_key TEXT NOT NULL UNIQUE,
password_salt TEXT NOT NULL, password_hash TEXT NOT NULL, created_at INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS sessions (
token_hash TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS sessions_account_id_idx ON sessions(account_id);
CREATE INDEX IF NOT EXISTS sessions_expires_at_idx ON sessions(expires_at);
CREATE TABLE IF NOT EXISTS cloud_saves (
account_id TEXT PRIMARY KEY REFERENCES accounts(id) ON DELETE CASCADE,
revision INTEGER NOT NULL, data_json TEXT NOT NULL, updated_at INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS manastorm_admin_config (
id INTEGER PRIMARY KEY CHECK (id = 1),
schema_version INTEGER NOT NULL,
revision INTEGER NOT NULL,
config_json TEXT NOT NULL,
updated_by_account_id TEXT REFERENCES accounts(id) ON DELETE SET NULL,
updated_by_username TEXT,
updated_at INTEGER
) STRICT;
CREATE TABLE IF NOT EXISTS online_groups (
id TEXT PRIMARY KEY,
leader_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
activity_json TEXT,
activity_revision INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
) STRICT;
CREATE TABLE IF NOT EXISTS online_group_members (
group_id TEXT NOT NULL REFERENCES online_groups(id) ON DELETE CASCADE,
account_id TEXT NOT NULL UNIQUE REFERENCES accounts(id) ON DELETE CASCADE,
character_json TEXT,
role TEXT,
joined_at INTEGER NOT NULL,
PRIMARY KEY (group_id, account_id)
) STRICT;
CREATE INDEX IF NOT EXISTS online_group_members_group_idx ON online_group_members(group_id, joined_at);
CREATE TABLE IF NOT EXISTS online_group_invites (
id TEXT PRIMARY KEY,
group_id TEXT NOT NULL REFERENCES online_groups(id) ON DELETE CASCADE,
inviter_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
invitee_account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
status TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER NOT NULL
) STRICT;
CREATE INDEX IF NOT EXISTS online_group_invites_invitee_idx
ON online_group_invites(invitee_account_id, status, expires_at);
`);
database.prepare(`
INSERT OR IGNORE INTO manastorm_admin_config (
id, schema_version, revision, config_json, updated_by_account_id, updated_by_username, updated_at
) VALUES (1, 1, 0, ?, NULL, NULL, NULL)
`).run(JSON.stringify(cloneDefaultManastormAdminConfig()));
const statements = {
accountByUsername: database.prepare("SELECT * FROM accounts WHERE username_key = ?"),
insertAccount: database.prepare("INSERT INTO accounts (id, username, username_key, password_salt, password_hash, created_at) VALUES (?, ?, ?, ?, ?, ?)"),
insertSession: database.prepare("INSERT INTO sessions (token_hash, account_id, created_at, expires_at) VALUES (?, ?, ?, ?)"),
sessionAccount: database.prepare(`
SELECT accounts.id, accounts.username, accounts.created_at, sessions.expires_at
FROM sessions JOIN accounts ON accounts.id = sessions.account_id
WHERE sessions.token_hash = ?
`),
deleteSession: database.prepare("DELETE FROM sessions WHERE token_hash = ?"),
deleteExpiredSessions: database.prepare("DELETE FROM sessions WHERE expires_at <= ?"),
cloudSave: database.prepare("SELECT revision, data_json, updated_at FROM cloud_saves WHERE account_id = ?"),
upsertCloudSave: database.prepare(`
INSERT INTO cloud_saves (account_id, revision, data_json, updated_at) VALUES (?, 1, ?, ?)
ON CONFLICT(account_id) DO UPDATE SET revision = cloud_saves.revision + 1,
data_json = excluded.data_json, updated_at = excluded.updated_at
RETURNING revision, updated_at
`),
manastormConfig: database.prepare("SELECT * FROM manastorm_admin_config WHERE id = 1"),
updateManastormConfig: database.prepare(`
UPDATE manastorm_admin_config
SET schema_version = 1,
revision = revision + 1,
config_json = ?,
updated_by_account_id = ?,
updated_by_username = ?,
updated_at = ?
WHERE id = 1 AND revision = ?
RETURNING revision, updated_at
`),
};
function issueSession(accountId) {
const token = randomBytes(32).toString("base64url");
const createdAt = Date.now();
const expiresAt = createdAt + sessionTtlDays * 24 * 60 * 60 * 1000;
statements.insertSession.run(tokenHash(token), accountId, createdAt, expiresAt);
return { token, expiresAt };
}
async function register(usernameInput, password) {
const username = typeof usernameInput === "string" ? usernameInput.trim() : "";
validateCredentials(username, password);
const usernameKey = username.toLowerCase();
if (statements.accountByUsername.get(usernameKey)) {
throw new GameDatabaseError("That account already exists.", 409, "account_exists");
}
const salt = randomBytes(16);
const digest = await derivePassword(password, salt);
const account = { id: `account-${randomUUID()}`, username, createdAt: Date.now() };
try {
statements.insertAccount.run(account.id, username, usernameKey, salt.toString("base64"), digest.toString("base64"), account.createdAt);
} catch (error) {
if (String(error?.message).includes("UNIQUE constraint failed")) {
throw new GameDatabaseError("That account already exists.", 409, "account_exists");
}
throw error;
}
return { account: publicAccount(account), ...issueSession(account.id) };
}
async function login(usernameInput, password) {
const username = typeof usernameInput === "string" ? usernameInput.trim() : "";
if (!username || typeof password !== "string" || !password) {
throw new GameDatabaseError("Enter your username and password.", 400, "missing_credentials");
}
const row = statements.accountByUsername.get(username.toLowerCase());
if (!row) throw new GameDatabaseError("Incorrect username or password.", 401, "invalid_credentials");
const actual = await derivePassword(password, Buffer.from(row.password_salt, "base64"));
const expected = Buffer.from(row.password_hash, "base64");
if (actual.length !== expected.length || !timingSafeEqual(actual, expected)) {
throw new GameDatabaseError("Incorrect username or password.", 401, "invalid_credentials");
}
return { account: publicAccount(row), ...issueSession(row.id) };
}
function authenticate(token) {
if (typeof token !== "string" || token.length < 20) {
throw new GameDatabaseError("Authentication required.", 401, "authentication_required");
}
const hash = tokenHash(token);
const row = statements.sessionAccount.get(hash);
if (!row || Number(row.expires_at) <= Date.now()) {
if (row) statements.deleteSession.run(hash);
throw new GameDatabaseError("Your session has expired. Please sign in again.", 401, "session_expired");
}
return publicAccount(row);
}
function getCloudSave(accountId) {
const row = statements.cloudSave.get(accountId);
if (!row) return { revision: 0, updatedAt: null, data: { version: 1, characters: [] } };
try {
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data: JSON.parse(row.data_json) };
} catch {
throw new GameDatabaseError("The cloud save is unreadable.", 500, "cloud_save_corrupt");
}
}
function putCloudSave(accountId, data) {
if (!data || typeof data !== "object" || Array.isArray(data)) {
throw new GameDatabaseError("Cloud save data must be an object.");
}
const now = Date.now();
const row = statements.upsertCloudSave.get(accountId, JSON.stringify(data), now);
return { revision: Number(row.revision), updatedAt: Number(row.updated_at), data };
}
function isGameMaster(account) {
// Recompute authority from the server allowlist for every privileged call.
return accountRoles(account?.username).includes("gm");
}
function getManastormConfig() {
const row = statements.manastormConfig.get();
try {
return {
revision: Number(row?.revision ?? 0),
updatedAt: row?.updated_at == null ? null : Number(row.updated_at),
updatedBy: row?.updated_by_username ?? null,
config: normalizeManastormAdminConfig(JSON.parse(row?.config_json ?? "null")),
};
} catch (error) {
console.error("Healer Man Manastorm configuration is corrupt; using safe defaults", error);
return { revision: 0, updatedAt: null, updatedBy: null, config: cloneDefaultManastormAdminConfig() };
}
}
function putManastormConfig(account, expectedRevision, config) {
if (!isGameMaster(account)) {
throw new GameDatabaseError("Game Master access is required.", 403, "gm_required");
}
if (!Number.isInteger(expectedRevision) || expectedRevision < 0) {
throw new GameDatabaseError("A valid expected revision is required.", 400, "invalid_revision");
}
let normalized;
try {
normalized = normalizeManastormAdminConfig(config);
} catch (error) {
throw new GameDatabaseError(error instanceof Error ? error.message : "Invalid Manastorm configuration.", 400, "invalid_manastorm_config");
}
const now = Date.now();
const row = statements.updateManastormConfig.get(
JSON.stringify(normalized),
account.id,
account.username,
now,
expectedRevision,
);
if (!row) {
throw new GameDatabaseError("The Manastorm configuration changed. Reload and try again.", 409, "revision_conflict");
}
return {
revision: Number(row.revision),
updatedAt: Number(row.updated_at),
updatedBy: account.username,
config: normalized,
};
}
function pruneExpiredGroupInvites() {
database.prepare(`
UPDATE online_group_invites SET status = 'expired'
WHERE status = 'pending' AND expires_at <= ?
`).run(Date.now());
}
function parseStoredJson(value, fallback = null) {
if (typeof value !== "string" || !value) return fallback;
try { return JSON.parse(value); } catch { return fallback; }
}
function groupMembership(accountId) {
return database.prepare(`
SELECT online_groups.*
FROM online_group_members
JOIN online_groups ON online_groups.id = online_group_members.group_id
WHERE online_group_members.account_id = ?
`).get(accountId);
}
function publicGroup(groupId) {
const group = database.prepare("SELECT * FROM online_groups WHERE id = ?").get(groupId);
if (!group) return null;
const members = database.prepare(`
SELECT accounts.id AS account_id, accounts.username,
online_group_members.character_json, online_group_members.role,
online_group_members.joined_at
FROM online_group_members
JOIN accounts ON accounts.id = online_group_members.account_id
WHERE online_group_members.group_id = ?
ORDER BY online_group_members.joined_at, accounts.username_key
`).all(groupId).map((member) => ({
accountId: member.account_id,
username: member.username,
character: parseStoredJson(member.character_json),
role: ["tank", "healer", "damage"].includes(member.role) ? member.role : null,
joinedAt: Number(member.joined_at),
}));
return {
id: group.id,
leaderAccountId: group.leader_account_id,
members,
activity: parseStoredJson(group.activity_json),
activityRevision: Number(group.activity_revision),
createdAt: Number(group.created_at),
updatedAt: Number(group.updated_at),
};
}
function onlineGroupState(accountId) {
pruneExpiredGroupInvites();
const membership = groupMembership(accountId);
const invitations = database.prepare(`
SELECT online_group_invites.id, online_group_invites.group_id,
online_group_invites.created_at, online_group_invites.expires_at,
accounts.id AS inviter_account_id, accounts.username AS inviter_username
FROM online_group_invites
JOIN accounts ON accounts.id = online_group_invites.inviter_account_id
WHERE online_group_invites.invitee_account_id = ?
AND online_group_invites.status = 'pending'
AND online_group_invites.expires_at > ?
ORDER BY online_group_invites.created_at DESC
`).all(accountId, Date.now()).map((invite) => ({
id: invite.id,
groupId: invite.group_id,
inviterAccountId: invite.inviter_account_id,
inviterUsername: invite.inviter_username,
createdAt: Number(invite.created_at),
expiresAt: Number(invite.expires_at),
}));
return { group: membership ? publicGroup(membership.id) : null, invitations };
}
function createOnlineGroup(accountId) {
const existing = groupMembership(accountId);
if (existing) return existing;
const id = `group-${randomUUID()}`;
const now = Date.now();
database.prepare(`
INSERT INTO online_groups (id, leader_account_id, activity_json, activity_revision, created_at, updated_at)
VALUES (?, ?, NULL, 0, ?, ?)
`).run(id, accountId, now, now);
database.prepare(`
INSERT INTO online_group_members (group_id, account_id, character_json, role, joined_at)
VALUES (?, ?, NULL, NULL, ?)
`).run(id, accountId, now);
return database.prepare("SELECT * FROM online_groups WHERE id = ?").get(id);
}
function inviteOnlineGroupMember(account, usernameInput) {
pruneExpiredGroupInvites();
const username = typeof usernameInput === "string" ? usernameInput.trim() : "";
if (!username) throw new GameDatabaseError("Enter the player's account name.", 400, "missing_username");
const invitee = statements.accountByUsername.get(username.toLowerCase());
if (!invitee) throw new GameDatabaseError("No online player has that account name.", 404, "player_not_found");
if (invitee.id === account.id) throw new GameDatabaseError("You cannot invite yourself.", 400, "cannot_invite_self");
if (groupMembership(invitee.id)) throw new GameDatabaseError("That player is already in a group.", 409, "player_already_grouped");
const group = createOnlineGroup(account.id);
const memberCount = Number(database.prepare("SELECT COUNT(*) AS count FROM online_group_members WHERE group_id = ?").get(group.id).count);
if (memberCount >= 5) throw new GameDatabaseError("Your group is already full.", 409, "group_full");
const existing = database.prepare(`
SELECT id FROM online_group_invites
WHERE group_id = ? AND invitee_account_id = ? AND status = 'pending' AND expires_at > ?
`).get(group.id, invitee.id, Date.now());
if (existing) throw new GameDatabaseError("That player already has an invitation from your group.", 409, "invite_exists");
const id = `invite-${randomUUID()}`;
const createdAt = Date.now();
const expiresAt = createdAt + 10 * 60 * 1000;
database.prepare(`
INSERT INTO online_group_invites (
id, group_id, inviter_account_id, invitee_account_id, status, created_at, expires_at
) VALUES (?, ?, ?, ?, 'pending', ?, ?)
`).run(id, group.id, account.id, invitee.id, createdAt, expiresAt);
return { id, expiresAt, state: onlineGroupState(account.id) };
}
function respondToOnlineGroupInvite(accountId, inviteId, accept) {
pruneExpiredGroupInvites();
const invite = database.prepare(`
SELECT * FROM online_group_invites
WHERE id = ? AND invitee_account_id = ? AND status = 'pending' AND expires_at > ?
`).get(inviteId, accountId, Date.now());
if (!invite) throw new GameDatabaseError("That group invitation is no longer available.", 404, "invite_not_found");
if (!accept) {
database.prepare("UPDATE online_group_invites SET status = 'declined' WHERE id = ?").run(invite.id);
return onlineGroupState(accountId);
}
if (groupMembership(accountId)) throw new GameDatabaseError("Leave your current group before accepting another invitation.", 409, "already_grouped");
const group = database.prepare("SELECT * FROM online_groups WHERE id = ?").get(invite.group_id);
if (!group) throw new GameDatabaseError("That group no longer exists.", 404, "group_not_found");
const memberCount = Number(database.prepare("SELECT COUNT(*) AS count FROM online_group_members WHERE group_id = ?").get(group.id).count);
if (memberCount >= 5) throw new GameDatabaseError("That group is already full.", 409, "group_full");
const now = Date.now();
database.prepare(`
INSERT INTO online_group_members (group_id, account_id, character_json, role, joined_at)
VALUES (?, ?, NULL, NULL, ?)
`).run(group.id, accountId, now);
database.prepare("UPDATE online_group_invites SET status = 'accepted' WHERE id = ?").run(invite.id);
database.prepare(`
UPDATE online_group_invites SET status = 'expired'
WHERE invitee_account_id = ? AND status = 'pending'
`).run(accountId);
database.prepare(`
UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ?
`).run(now, group.id);
return onlineGroupState(accountId);
}
function leaveOnlineGroup(accountId) {
const group = groupMembership(accountId);
if (!group) return onlineGroupState(accountId);
database.prepare("DELETE FROM online_group_members WHERE group_id = ? AND account_id = ?").run(group.id, accountId);
const remaining = database.prepare(`
SELECT account_id FROM online_group_members WHERE group_id = ? ORDER BY joined_at LIMIT 1
`).get(group.id);
if (!remaining) {
database.prepare("DELETE FROM online_groups WHERE id = ?").run(group.id);
} else {
database.prepare(`
UPDATE online_groups
SET leader_account_id = CASE WHEN leader_account_id = ? THEN ? ELSE leader_account_id END,
activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ?
WHERE id = ?
`).run(accountId, remaining.account_id, Date.now(), group.id);
}
return onlineGroupState(accountId);
}
function removeOnlineGroupMember(accountId, targetAccountId) {
const group = groupMembership(accountId);
if (!group || group.leader_account_id !== accountId) {
throw new GameDatabaseError("Only the group leader can remove players.", 403, "leader_required");
}
if (targetAccountId === accountId) return leaveOnlineGroup(accountId);
const removed = database.prepare(`
DELETE FROM online_group_members WHERE group_id = ? AND account_id = ?
`).run(group.id, targetAccountId);
if (!removed.changes) throw new GameDatabaseError("That player is not in your group.", 404, "member_not_found");
database.prepare(`
UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ?
`).run(Date.now(), group.id);
return onlineGroupState(accountId);
}
function normalizeGroupCharacter(character) {
if (!character || typeof character !== "object" || Array.isArray(character)) return null;
const id = String(character.id ?? "").slice(0, 100);
const name = String(character.name ?? "").trim().slice(0, 30);
const classId = String(character.classId ?? "").slice(0, 50);
if (!id || !name || !classId) throw new GameDatabaseError("Select a valid character for the group.", 400, "invalid_group_character");
let appearance = {};
if (character.appearance && typeof character.appearance === "object" && !Array.isArray(character.appearance)) {
try {
const serialized = JSON.stringify(character.appearance);
if (serialized.length <= 8 * 1024) appearance = JSON.parse(serialized);
} catch {
appearance = {};
}
}
return {
id,
name,
classId,
categoryId: String(character.categoryId ?? "wow").slice(0, 20),
raceId: String(character.raceId ?? "human").slice(0, 50),
gender: character.gender === "female" ? "female" : "male",
level: Math.max(1, Math.min(1000, Math.trunc(Number(character.level) || 1))),
appearance,
};
}
function updateOnlineGroupMember(accountId, character, role) {
const group = groupMembership(accountId);
if (!group) throw new GameDatabaseError("Join or create a group first.", 409, "group_required");
if (!["tank", "healer", "damage"].includes(role)) {
throw new GameDatabaseError("Choose a valid party role.", 400, "invalid_party_role");
}
database.prepare(`
UPDATE online_group_members SET character_json = ?, role = ?
WHERE group_id = ? AND account_id = ?
`).run(JSON.stringify(normalizeGroupCharacter(character)), role, group.id, accountId);
database.prepare("UPDATE online_groups SET updated_at = ? WHERE id = ?").run(Date.now(), group.id);
return onlineGroupState(accountId);
}
function startOnlineGroupActivity(accountId, input) {
const group = groupMembership(accountId);
if (!group || group.leader_account_id !== accountId) {
throw new GameDatabaseError("Only the group leader can start an activity.", 403, "leader_required");
}
const type = input?.type;
if (type !== "dungeon" && type !== "manastorm") {
throw new GameDatabaseError("Choose a Dungeon or Manastorm.", 400, "invalid_activity_type");
}
const contentId = String(input?.contentId ?? "").trim().slice(0, 120);
if (!contentId) throw new GameDatabaseError("Choose content before starting.", 400, "missing_content");
const members = database.prepare(`
SELECT account_id, character_json, role FROM online_group_members WHERE group_id = ? ORDER BY joined_at
`).all(group.id);
if (members.some((member) => !member.character_json || !["tank", "healer", "damage"].includes(member.role))) {
throw new GameDatabaseError("Every player must select a character and party role before the group can start.", 409, "group_not_ready");
}
const fillWithAi = input?.fillWithAi !== false;
const requestedSize = Math.max(1, Math.min(5, Math.trunc(Number(input?.partySize) || 5)));
const partySize = fillWithAi ? Math.max(members.length, requestedSize) : members.length;
const activity = {
id: `activity-${randomUUID()}`,
type,
contentId,
fillWithAi,
partySize,
startingLevel: type === "manastorm"
? Math.max(1, Math.trunc(Number(input?.startingLevel) || 1))
: null,
startedAt: Date.now(),
};
database.prepare(`
UPDATE online_groups
SET activity_json = ?, activity_revision = activity_revision + 1, updated_at = ?
WHERE id = ?
`).run(JSON.stringify(activity), activity.startedAt, group.id);
return onlineGroupState(accountId);
}
function clearOnlineGroupActivity(accountId) {
const group = groupMembership(accountId);
if (!group || group.leader_account_id !== accountId) {
throw new GameDatabaseError("Only the group leader can reset the activity.", 403, "leader_required");
}
database.prepare(`
UPDATE online_groups SET activity_json = NULL, activity_revision = activity_revision + 1, updated_at = ? WHERE id = ?
`).run(Date.now(), group.id);
return onlineGroupState(accountId);
}
return {
databasePath,
register,
login,
authenticate,
logout: (token) => { if (typeof token === "string" && token) statements.deleteSession.run(tokenHash(token)); },
getCloudSave,
putCloudSave,
isGameMaster,
getManastormConfig,
putManastormConfig,
onlineGroupState,
inviteOnlineGroupMember,
respondToOnlineGroupInvite,
leaveOnlineGroup,
removeOnlineGroupMember,
updateOnlineGroupMember,
startOnlineGroupActivity,
clearOnlineGroupActivity,
pruneExpiredSessions: () => Number(statements.deleteExpiredSessions.run(Date.now()).changes),
close: () => database.close(),
};
}