mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
143 lines
5.1 KiB
TypeScript
143 lines
5.1 KiB
TypeScript
/**
|
|
* Account storage on the shared `recflare` D1 database. Each account is a single
|
|
* JSON blob in the `data` column; queryable fields (AccountId, Username) are
|
|
* SQLite generated (virtual) columns extracted from that JSON and indexed —
|
|
* the same JSON-blob pattern the `rooms` worker uses.
|
|
*
|
|
* The `auth` worker owns this schema/migration (see migrations/0001_accounts.sql,
|
|
* applied with its own `migrations_table` so it doesn't clash with the rooms
|
|
* migrations that share the database). Other workers bind the table read/write
|
|
* and keep these helpers in sync.
|
|
*/
|
|
|
|
/** Schema DDL (mirror of migrations/0001_accounts.sql, sans the seed INSERTs). */
|
|
export const SCHEMA_DDL: string[] = [
|
|
`CREATE TABLE IF NOT EXISTS accounts (
|
|
data TEXT NOT NULL,
|
|
account_id INTEGER GENERATED ALWAYS AS (json_extract(data, '$.AccountId')) VIRTUAL,
|
|
username_lower TEXT GENERATED ALWAYS AS (lower(json_extract(data, '$.Username'))) VIRTUAL
|
|
)`,
|
|
`CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_account_id ON accounts (account_id)`,
|
|
`CREATE INDEX IF NOT EXISTS idx_accounts_username_lower ON accounts (username_lower)`,
|
|
]
|
|
|
|
/** Client-facing account shape (PascalCase, as the client expects). */
|
|
export interface Account {
|
|
AccountId: number
|
|
Username: string
|
|
DisplayName: string
|
|
ProfileImage: string
|
|
IsJunior: boolean
|
|
Platforms: number
|
|
PersonalPronouns: number
|
|
IdentityFlags: number
|
|
CreatedAt: string
|
|
}
|
|
|
|
interface AccountRow {
|
|
data: string
|
|
}
|
|
|
|
const parseOne = (row: AccountRow | null): Account | null =>
|
|
row ? (JSON.parse(row.data) as Account) : null
|
|
const parseAll = (rows: AccountRow[]): Account[] => rows.map((r) => JSON.parse(r.data) as Account)
|
|
|
|
/** Word lists for auto-assigned usernames (players don't pick one on signup). */
|
|
const ADJECTIVES = [
|
|
'Swift', 'Brave', 'Clever', 'Happy', 'Mighty', 'Lucky', 'Sunny', 'Cosmic',
|
|
'Witty', 'Nimble', 'Jolly', 'Bold', 'Gentle', 'Fuzzy', 'Speedy', 'Shiny',
|
|
]
|
|
const NOUNS = [
|
|
'Fox', 'Otter', 'Falcon', 'Panda', 'Tiger', 'Comet', 'Maple', 'Pixel',
|
|
'Robin', 'Wolf', 'Koala', 'Dragon', 'Penguin', 'Badger', 'Heron', 'Lynx',
|
|
]
|
|
|
|
/** A random, readable username (e.g. "SwiftFox4821"). */
|
|
export function randomUsername(): string {
|
|
const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)]
|
|
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)]
|
|
const n = Math.floor(Math.random() * 10000)
|
|
return `${adj}${noun}${n}`
|
|
}
|
|
|
|
/**
|
|
* Build a full account object from an id, applying default fallbacks for any
|
|
* column the caller doesn't override. Used both to synthesize accounts that
|
|
* aren't in the DB and as the base for a freshly created account.
|
|
*/
|
|
export function defaultAccount(id: number, overrides: Partial<Account> = {}): Account {
|
|
return {
|
|
AccountId: id,
|
|
Username: `Player${id}`,
|
|
DisplayName: `Player${id}`,
|
|
ProfileImage: 'DefaultProfileImage.jpg',
|
|
IsJunior: false,
|
|
Platforms: 0,
|
|
PersonalPronouns: 0,
|
|
IdentityFlags: 0,
|
|
CreatedAt: new Date().toISOString(),
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
/** Look up a single account by AccountId. */
|
|
export async function getAccount(db: D1Database, id: number): Promise<Account | null> {
|
|
return parseOne(
|
|
await db.prepare('SELECT data FROM accounts WHERE account_id = ?1').bind(id).first<AccountRow>()
|
|
)
|
|
}
|
|
|
|
/** Look up multiple accounts by AccountId (order not guaranteed). */
|
|
export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<Account[]> {
|
|
if (ids.length === 0) return []
|
|
const placeholders = ids.map((_, i) => `?${i + 1}`).join(',')
|
|
const { results } = await db
|
|
.prepare(`SELECT data FROM accounts WHERE account_id IN (${placeholders})`)
|
|
.bind(...ids)
|
|
.all<AccountRow>()
|
|
return parseAll(results)
|
|
}
|
|
|
|
/**
|
|
* Merge `overrides` into the account row for `id` and persist it. Reads the
|
|
* current account (falling back to a synthesized default), applies the
|
|
* overrides, and writes the whole JSON blob back — inserting the row when the
|
|
* account isn't in the table yet. Returns the updated account.
|
|
*/
|
|
export async function updateAccount(
|
|
db: D1Database,
|
|
id: number,
|
|
overrides: Partial<Account>
|
|
): Promise<Account> {
|
|
const current = (await getAccount(db, id)) ?? defaultAccount(id)
|
|
const updated: Account = { ...current, ...overrides, AccountId: id }
|
|
const data = JSON.stringify(updated)
|
|
const res = await db
|
|
.prepare('UPDATE accounts SET data = ?2 WHERE account_id = ?1')
|
|
.bind(id, data)
|
|
.run()
|
|
if (!res.meta.changes) {
|
|
await db.prepare('INSERT INTO accounts (data) VALUES (?1)').bind(data).run()
|
|
}
|
|
return updated
|
|
}
|
|
|
|
/**
|
|
* Create and persist a new account. The id is the next free integer (above the
|
|
* seeded system accounts); the username is auto-assigned (players don't choose
|
|
* one initially) and the display name defaults to it.
|
|
*/
|
|
export async function createAccount(
|
|
db: D1Database,
|
|
overrides: Partial<Account> = {}
|
|
): Promise<Account> {
|
|
const row = await db
|
|
.prepare('SELECT COALESCE(MAX(account_id), 1) + 1 AS next FROM accounts')
|
|
.first<{ next: number }>()
|
|
const id = row?.next ?? 2
|
|
const username = overrides.Username ?? randomUsername()
|
|
const account = defaultAccount(id, { Username: username, DisplayName: username, ...overrides })
|
|
await db.prepare('INSERT INTO accounts (data) VALUES (?1)').bind(JSON.stringify(account)).run()
|
|
return account
|
|
}
|