Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions backend/migrations/001_init.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
-- 001_init.sql
-- Initial schema for the snake leaderboard.
--
-- Three tables:
-- users — registered players (one row per unique player handle)
-- sessions — one row per game played (start/end time, final score, optional metadata)
-- scores — denormalized "best/notable scores" feed used by the leaderboard
--
-- We keep `scores` separate from `sessions` so the leaderboard read path is a
-- cheap index scan and not a `MAX(score) GROUP BY user` over the full session log.
--
-- All statements are idempotent so a bootstrap helper can safely re-run them.

BEGIN;

-- ---------------------------------------------------------------------------
-- users
-- ---------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS users (
id BIGSERIAL PRIMARY KEY,
-- Display handle. Case-insensitive uniqueness is enforced via the index
-- below (CITEXT would be nicer but adds an extension dependency).
player TEXT NOT NULL,
-- Opaque bearer token used by the API for `X-Player-Token` auth. Stored
-- as a hex string; rotation is just an UPDATE.
api_token TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE UNIQUE INDEX IF NOT EXISTS users_player_lower_uniq
ON users (LOWER(player));

CREATE UNIQUE INDEX IF NOT EXISTS users_api_token_uniq
ON users (api_token);

-- ---------------------------------------------------------------------------
-- sessions
-- ---------------------------------------------------------------------------
-- One row per played game. `ended_at IS NULL` means in-progress.
CREATE TABLE IF NOT EXISTS sessions (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ended_at TIMESTAMPTZ,
final_score INTEGER NOT NULL DEFAULT 0 CHECK (final_score >= 0),
-- Free-form JSON for client-side metadata (board size, tick rate, etc.).
-- Useful for analytics; the leaderboard does not read it.
meta JSONB NOT NULL DEFAULT '{}'::jsonb
);

CREATE INDEX IF NOT EXISTS sessions_user_started_idx
ON sessions (user_id, started_at DESC);

-- ---------------------------------------------------------------------------
-- scores
-- ---------------------------------------------------------------------------
-- Append-only feed of submitted scores. The leaderboard query reads this
-- table directly. We index `(score DESC, created_at ASC)` so that
-- "top-N global" is a single index scan and ties resolve by who got there
-- first.
CREATE TABLE IF NOT EXISTS scores (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
session_id BIGINT REFERENCES sessions(id) ON DELETE SET NULL,
score INTEGER NOT NULL CHECK (score >= 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

-- Top-N leaderboard read path.
CREATE INDEX IF NOT EXISTS scores_score_created_idx
ON scores (score DESC, created_at ASC);

-- "Best score per user" lookups (`GET /api/users/:id/best`).
CREATE INDEX IF NOT EXISTS scores_user_score_idx
ON scores (user_id, score DESC);

COMMIT;
28 changes: 28 additions & 0 deletions backend/migrations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Migrations

Plain numbered SQL files. Apply with `psql`:

```bash
DATABASE_URL=postgres://user:pass@localhost:5432/snake \
psql "$DATABASE_URL" -f backend/migrations/001_init.sql
```

Or, from a Node script, use `backend/src/db.ts` which exposes `runMigrations(client)` for tests/bootstrap.

All statements are wrapped in `BEGIN/COMMIT` and use `IF NOT EXISTS`, so re-running on an already-initialised database is a no-op.

## Schema overview

| Table | Purpose |
|------------|----------------------------------------------------------------------|
| `users` | One row per player handle. Holds the bearer token used for write auth. |
| `sessions` | One row per game played. `ended_at IS NULL` while in progress. |
| `scores` | Append-only feed of submitted scores. Indexed for top-N reads. |

### Indexes

- `users_player_lower_uniq` — case-insensitive unique handle.
- `users_api_token_uniq` — token lookup for `X-Player-Token` auth.
- `sessions_user_started_idx` — recent-sessions-per-user reads.
- `scores_score_created_idx` — global top-N leaderboard (score DESC, ties resolved by earliest submit).
- `scores_user_score_idx` — best-score-per-user lookups.
10 changes: 8 additions & 2 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,22 @@
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.json",
"start": "node dist/server.js",
"test": "node --test --import tsx src/**/*.test.ts",
"test": "node --test --import tsx 'src/**/*.test.ts'",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@snake/shared": "*",
"express": "^4.21.1"
"express": "^4.21.1",
"pg": "^8.13.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.16.10",
"@types/pg": "^8.11.10",
"@types/supertest": "^6.0.2",
"pg-mem": "^3.0.4",
"supertest": "^7.0.0",
"tsx": "^4.19.1",
"typescript": "^5.5.4"
}
Expand Down
141 changes: 141 additions & 0 deletions backend/src/__tests__/leaderboard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/**
* Integration tests for the leaderboard router.
*
* Uses `pg-mem` to provide an in-memory PostgreSQL-compatible database, so
* the suite runs in CI without a real Postgres. The router is wired with the
* mem DB via the `db` option; nothing in the code path under test is mocked.
*/

import { test, before } from 'node:test'
import assert from 'node:assert/strict'
import express from 'express'
import request from 'supertest'
import { newDb } from 'pg-mem'
import { leaderboardRouter } from '../routes/leaderboard.js'
import { runMigrations } from '../db.js'
import type { Db } from '../db.js'

let app: express.Express

before(async () => {
const mem = newDb({ autoCreateForeignKeyIndices: true })
// pg-mem doesn't ship NOW()/jsonb-functions identically to PG; the
// defaults are good enough for our schema.
const adapter = mem.adapters.createPg()
const pool = new adapter.Pool() as unknown as Db

// Apply real migrations (the same .sql files used in production).
await runMigrations(pool)

app = express()
app.use(express.json())
app.use('/api', leaderboardRouter({ db: pool }))
})

test('register -> submit -> leaderboard -> best round-trip', async () => {
// Register two players.
const reg1 = await request(app)
.post('/api/users/register')
.send({ player: 'alice' })
.expect(201)
assert.equal(reg1.body.player, 'alice')
assert.ok(reg1.body.id, 'id present')
assert.ok(reg1.body.token && typeof reg1.body.token === 'string', 'token returned')

const reg2 = await request(app)
.post('/api/users/register')
.send({ player: 'bob' })
.expect(201)

// Submit several scores.
await request(app)
.post('/api/scores')
.set('X-Player-Token', reg1.body.token)
.send({ score: 50 })
.expect(201)

const r2 = await request(app)
.post('/api/scores')
.set('X-Player-Token', reg1.body.token)
.send({ score: 120, meta: { boardSize: 20 } })
.expect(201)
assert.equal(r2.body.entry.score, 120)
assert.equal(r2.body.bestScore, 120, 'best updated to 120')

await request(app)
.post('/api/scores')
.set('X-Player-Token', reg2.body.token)
.send({ score: 80 })
.expect(201)

// Leaderboard should be 120 (alice), 80 (bob), 50 (alice).
const lb = await request(app).get('/api/leaderboard?limit=10').expect(200)
assert.equal(lb.body.entries.length, 3)
assert.equal(lb.body.entries[0].score, 120)
assert.equal(lb.body.entries[0].player, 'alice')
assert.equal(lb.body.entries[0].rank, 1)
assert.equal(lb.body.entries[1].score, 80)
assert.equal(lb.body.entries[2].score, 50)
assert.ok(lb.body.generatedAt, 'generatedAt set')

// Best for alice should be 120.
const best = await request(app)
.get(`/api/users/${reg1.body.id}/best`)
.expect(200)
assert.equal(best.body.entry.score, 120)
assert.equal(best.body.entry.player, 'alice')
})

test('rejects score submit without token', async () => {
await request(app).post('/api/scores').send({ score: 10 }).expect(401)
})

test('rejects score submit with invalid token', async () => {
await request(app)
.post('/api/scores')
.set('X-Player-Token', 'definitely-not-a-real-token')
.send({ score: 10 })
.expect(401)
})

test('rejects negative scores via zod validation', async () => {
const reg = await request(app)
.post('/api/users/register')
.send({ player: 'carol' })
.expect(201)
const r = await request(app)
.post('/api/scores')
.set('X-Player-Token', reg.body.token)
.send({ score: -1 })
.expect(400)
assert.equal(r.body.error, 'validation failed')
})

test('rejects malformed player handle on register', async () => {
await request(app).post('/api/users/register').send({ player: '' }).expect(400)
await request(app)
.post('/api/users/register')
.send({ player: 'has spaces' })
.expect(400)
})

test('register is idempotent on case-insensitive handle', async () => {
const a = await request(app).post('/api/users/register').send({ player: 'Dave' }).expect(201)
const b = await request(app).post('/api/users/register').send({ player: 'dave' }).expect(201)
assert.equal(a.body.id, b.body.id, 'same user returned for case variant')
})

test('leaderboard limit is clamped to a sane range', async () => {
const tooBig = await request(app).get('/api/leaderboard?limit=10000').expect(400)
assert.equal(tooBig.body.error, 'validation failed')
const tooSmall = await request(app).get('/api/leaderboard?limit=0').expect(400)
assert.equal(tooSmall.body.error, 'validation failed')
})

test('users/:id/best returns 404 for unknown user', async () => {
await request(app).get('/api/users/9999999/best').expect(404)
})

test('users/:id/best rejects non-numeric ids', async () => {
await request(app).get('/api/users/not-a-number/best').expect(400)
})
67 changes: 67 additions & 0 deletions backend/src/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Database access layer.
*
* Why a thin abstraction:
* - The leaderboard backend talks to PostgreSQL via `pg`, but typechecking
* and unit tests must not require a running database. `getPool()` is lazy:
* a Pool is only constructed on first call.
* - Tests can swap in any object that implements the minimal `Db` interface
* below (e.g. `pg-mem`'s adapter) by calling `setDb()`.
* - All query helpers go through this module so the rest of the codebase
* never imports `pg` directly. Keeps the surface area small.
*/

import { readFile, readdir } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

/** Minimal subset of `pg.Pool` we depend on. */
export interface Db {
query<T = unknown>(text: string, params?: unknown[]): Promise<{ rows: T[]; rowCount: number | null }>
}

let _db: Db | null = null

/**
* Returns the active database handle, lazily constructing a `pg.Pool` from
* `DATABASE_URL` on first call. Throws if `DATABASE_URL` is not set and no
* test harness has called `setDb()`.
*/
export async function getDb(): Promise<Db> {
if (_db) return _db
const url = process.env.DATABASE_URL
if (!url) {
throw new Error(
'DATABASE_URL is not set. Either configure it, or call setDb() with a test adapter.',
)
}
// Imported lazily so `pg` is not required for typecheck or tests that
// never touch a real database.
const { Pool } = await import('pg')
const pool = new Pool({ connectionString: url })
_db = pool as unknown as Db
return _db
}

/** Inject a custom adapter (e.g. pg-mem) for tests. */
export function setDb(db: Db | null): void {
_db = db
}

/**
* Apply every `.sql` file in `backend/migrations/` in lexicographic order.
* Files are idempotent so re-running is safe.
*/
export async function runMigrations(db?: Db): Promise<void> {
const handle = db ?? (await getDb())
const here = dirname(fileURLToPath(import.meta.url))
// src/ -> backend/migrations
const migrationsDir = join(here, '..', 'migrations')
const files = (await readdir(migrationsDir))
.filter((f) => f.endsWith('.sql'))
.sort()
for (const file of files) {
const sql = await readFile(join(migrationsDir, file), 'utf8')
await handle.query(sql)
}
}
Loading