migrating more stuff into domain pkg

This commit is contained in:
Devin Zuczek
2026-07-09 00:56:38 -04:00
parent f0a273a3c4
commit 6e9d857ba6
23 changed files with 588 additions and 268 deletions
+5
View File
@@ -0,0 +1,5 @@
# domain
Shared domain enums and constants for the recflare workers. Single source of
truth for the numeric values the client (and our D1 JSON blobs) encode, so the
same magic numbers aren't re-hardcoded per worker.
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@repo/domain",
"version": "0.1.0",
"private": true,
"sideEffects": false,
"type": "module",
"main": "src/index.ts",
"scripts": {
"check:lint": "run-oxlint",
"check:types": "run-tsc"
},
"devDependencies": {
"@cloudflare/workers-types": "4.20260630.1",
"@repo/tools": "workspace:*",
"@repo/typescript-config": "workspace:*"
}
}
+252
View File
@@ -0,0 +1,252 @@
/**
* 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 apps/auth/migrations/
* 0001_accounts.sql, applied with its own `migrations_table` so it doesn't clash
* with the rooms migrations that share the database). This module is the single
* source of truth for the helpers; the `auth` and `accounts` workers both import
* it from `@repo/domain` (each uses the subset it needs).
*/
/** Schema DDL (mirror of migrations 0001_accounts + 0002_avatar, sans seed INSERTs). */
export const SCHEMA_DDL: string[] = [
`CREATE TABLE IF NOT EXISTS accounts (
data TEXT NOT NULL,
avatar TEXT,
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 (camelCase, exactly as the client's AccountDTO). */
export interface Account {
accountId: number
username: string
displayName: string
profileImage: string
isJunior: boolean
platforms: number
personalPronouns: number
identityFlags: number
createdAt: string
/** Set via POST /account/me/email; absent until the player provides one. */
email?: string
/** Set via POST /account/me/phone; absent until the player provides one. */
phone?: string
/** Set via PUT /account/me/bio; read back via GET /account/:id/bio. */
bio?: string
/** Remaining username changes; decremented by PUT /account/me/username. */
availableUsernameChanges?: number
/**
* PBKDF2 `salt:hash` for credential login; set via /account/me/changepassword
* or create_account. Kept in the JSON blob but never projected into a public
* DTO (the DTO builders pick only known fields), so it doesn't leak.
*/
passwordHash?: 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 a single account by username (case-insensitive), or null if none. */
export async function getAccountByUsername(
db: D1Database,
username: string
): Promise<Account | null> {
return parseOne(
await db
.prepare('SELECT data FROM accounts WHERE username_lower = ?1')
.bind(username.toLowerCase())
.first<AccountRow>()
)
}
/** Default cap on how many matches `searchAccounts` returns. */
export const SEARCH_LIMIT = 20
/** Escape LIKE wildcards so user input is matched literally (using `\` as the escape char). */
const escapeLike = (s: string): string => s.replace(/[\\%_]/g, '\\$&')
/**
* Prefix-search accounts by username (case-insensitive, "begins with"), ordered
* alphabetically. Backed by the indexed `username_lower` generated column, so the
* `name%` LIKE stays index-friendly. Returns up to `limit` matches.
*/
export async function searchAccounts(
db: D1Database,
name: string,
limit = SEARCH_LIMIT
): Promise<Account[]> {
const q = name.trim().toLowerCase()
if (q === '') return []
const { results } = await db
.prepare(
`SELECT data FROM accounts WHERE username_lower LIKE ?1 ESCAPE '\\' ORDER BY username_lower LIMIT ?2`
)
.bind(`${escapeLike(q)}%`, limit)
.all<AccountRow>()
return parseAll(results)
}
/** 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, displayName: username, ...overrides })
await db.prepare('INSERT INTO accounts (data) VALUES (?1)').bind(JSON.stringify(account)).run()
return account
}
/**
* Read the account's stored password hash (`salt:hash`), or null when the account
* has none / doesn't exist. Kept in the account JSON blob but out of the public
* account DTO (which projects only known fields), so it never leaks.
*/
export async function getPasswordHash(db: D1Database, id: number): Promise<string | null> {
const row = await db
.prepare(
"SELECT json_extract(data, '$.passwordHash') AS hash FROM accounts WHERE account_id = ?1"
)
.bind(id)
.first<{ hash: string | null }>()
return row?.hash ?? null
}
/** Persist the account's password hash. Returns false when no such account exists. */
export async function setPasswordHash(db: D1Database, id: number, hash: string): Promise<boolean> {
const { meta } = await db
.prepare(
"UPDATE accounts SET data = json_set(data, '$.passwordHash', ?2) WHERE account_id = ?1"
)
.bind(id, hash)
.run()
return meta.changes > 0
}
+32
View File
@@ -0,0 +1,32 @@
/**
* Domain enums — the numeric codes the Rec Room client encodes into the room /
* room-instance JSON we store in D1. Single source of truth so workers reference
* a name instead of re-hardcoding the integer. Regular (not `const`) enums, since
* the tsconfig sets `isolatedModules` (which disallows `const enum` across files).
*/
/** The kind of a room instance (live session), matching the client's `RoomInstanceType`. */
export enum RoomInstanceType {
Public = 0,
Private = 1,
Dormroom = 2,
Event = 3,
Meetup = 4,
Clubhouse = 5,
}
/** A room's (or image's) visibility, matching the client's `RoomAccessibility`. */
export enum Accessibility {
Private = 0,
Public = 1,
Unlisted = 2,
}
/**
* A room-role tier (the `Role` byte on a room's `Roles` entries). Named tiers we
* reference by value today — the owner (max byte) and co-owner.
*/
export enum Role {
CoOwner = 30,
Owner = 255,
}
+2
View File
@@ -0,0 +1,2 @@
export { RoomInstanceType, Accessibility, Role } from './enums'
export * from './accounts-db'
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "@repo/typescript-config/workers-lib.json",
// domain has no vitest of its own, so pull in only the D1/Workers ambient types
// (drop @cloudflare/vitest-pool-workers/types that workers-lib.json adds).
"compilerOptions": {
"types": ["@cloudflare/workers-types"]
}
}
+20 -2
View File
@@ -48,11 +48,24 @@ HOST="$SUBDOMAIN.$DOMAIN"
# Secrets Store — RECFLARE_SECRETS_STORE: a single store id (all workers bind the
# one shared store for the JWT signing key).
CONFIG="wrangler.jsonc"
# Workers built with @cloudflare/vite-plugin (e.g. the React SPA in www) emit a
# deploy-ready config into dist/<dir>/ that carries the built worker
# (main: index.js), the resolved assets.directory, and no_bundle. The committed
# wrangler.jsonc leaves assets.directory out on purpose — the plugin fills it in —
# so deploying the source config fails with "assets ... missing the required
# directory property". Prefer the generated config when it exists. These workers
# have no D1/KV/Secrets bindings, so the id-splicing below is skipped.
VITE_CONFIG="dist/$DIR/wrangler.json"
if [ -f "$VITE_CONFIG" ]; then
CONFIG="$VITE_CONFIG"
fi
NEEDS_D1=$(grep -q '"database_id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true)
NEEDS_KV=$(grep -q '"id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true)
NEEDS_STORE=$(grep -q '"store_id": *"local"' wrangler.jsonc 2>/dev/null && echo 1 || true)
if [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; then
if [ "$CONFIG" = "wrangler.jsonc" ] && { [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; }; then
CONFIG="wrangler.generated.jsonc"
# Generated alongside the original so its relative paths (main, migrations_dir)
# still resolve. Gitignored; removed on exit so `wrangler dev` is unaffected.
@@ -116,6 +129,11 @@ if [ -n "$NEEDS_D1" ] || [ -n "$NEEDS_KV" ] || [ -n "$NEEDS_STORE" ]; then
fi
fi
# Vite-built configs set no_bundle (vite already bundled and minified), which is
# incompatible with --minify. Only pass --minify when wrangler does the bundling.
MINIFY="--minify"
[ "$CONFIG" = "$VITE_CONFIG" ] && MINIFY=""
# Deploy with wrangler using the extracted values as binding variables
echo "Deploying worker $NAME version $VERSION to $HOST"
wrangler deploy \
@@ -124,5 +142,5 @@ wrangler deploy \
--var SENTRY_RELEASE:"$VERSION" \
--var DOMAIN:"$DOMAIN" \
--domain "$HOST" \
--minify \
$MINIFY \
"$@"