feat: database layer with better-sqlite3, full schema and query functions

Implements Task 4 (TDD): wrote failing tests first, then the full SQLite
database layer covering profiles, magic tokens, sessions, user progress,
mnemonics, and letter stats. All 14 tests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-30 00:34:59 +00:00
parent cf2081f643
commit 9b31119c7a
4 changed files with 397 additions and 1 deletions
+2 -1
View File
@@ -180,7 +180,8 @@ dist
*.db
*.sqlite
*.sqlite3
data/
data/*
!data/.gitkeep
# Build artifacts
build/
View File
+253
View File
@@ -0,0 +1,253 @@
'use strict'
const Database = require('better-sqlite3')
const path = require('path')
let db
function initDb(dbPath) {
const resolvedPath = dbPath || path.join(__dirname, '../data/morsequest.db')
const instance = new Database(resolvedPath)
instance.pragma('journal_mode = WAL')
instance.pragma('foreign_keys = ON')
instance.exec(`
CREATE TABLE IF NOT EXISTS profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT UNIQUE NOT NULL,
display_name TEXT,
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS magic_tokens (
token TEXT PRIMARY KEY,
profile_id INTEGER NOT NULL,
expires_at INTEGER NOT NULL,
used INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS sessions (
token TEXT PRIMARY KEY,
profile_id INTEGER NOT NULL,
created_at INTEGER NOT NULL,
last_seen INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_progress (
profile_id INTEGER PRIMARY KEY,
level INTEGER NOT NULL DEFAULT 1,
score INTEGER NOT NULL DEFAULT 0,
streak INTEGER NOT NULL DEFAULT 0,
best_streak INTEGER NOT NULL DEFAULT 0,
total_correct INTEGER NOT NULL DEFAULT 0,
total_attempts INTEGER NOT NULL DEFAULT 0,
last_session_at INTEGER,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS user_mnemonics (
profile_id INTEGER NOT NULL,
letter TEXT NOT NULL,
phrase TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (profile_id, letter)
);
CREATE TABLE IF NOT EXISTS letter_stats (
profile_id INTEGER NOT NULL,
letter TEXT NOT NULL,
correct INTEGER NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
PRIMARY KEY (profile_id, letter)
);
`)
db = instance
return {
_db: instance,
createProfile,
findProfileByEmail,
findProfileById,
updateProfileSeen,
createMagicToken,
useMagicToken,
createSession,
getSession,
deleteSession,
getOrCreateProgress,
updateLevel,
recordAnswer,
getMnemonics,
saveMnemonic,
getLetterStats,
getAdminUsers,
getAdminStats,
}
}
function createProfile(email, displayName) {
const now = Date.now()
const stmt = db.prepare(
'INSERT INTO profiles (email, display_name, created_at, last_seen) VALUES (?,?,?,?) RETURNING *'
)
return stmt.get(email, displayName || null, now, now)
}
function findProfileByEmail(email) {
return db.prepare('SELECT * FROM profiles WHERE email = ?').get(email) ?? null
}
function findProfileById(id) {
return db.prepare('SELECT * FROM profiles WHERE id = ?').get(id) ?? null
}
function updateProfileSeen(id) {
db.prepare('UPDATE profiles SET last_seen = ? WHERE id = ?').run(Date.now(), id)
}
function createMagicToken(token, profileId, expiresAt) {
db.prepare('INSERT INTO magic_tokens (token, profile_id, expires_at) VALUES (?,?,?)').run(
token, profileId, expiresAt
)
}
function useMagicToken(token) {
const row = db
.prepare('SELECT * FROM magic_tokens WHERE token = ? AND used = 0 AND expires_at > ?')
.get(token, Date.now())
if (!row) return null
db.prepare('UPDATE magic_tokens SET used = 1 WHERE token = ?').run(token)
return row.profile_id
}
function createSession(token, profileId) {
const now = Date.now()
db.prepare(
'INSERT INTO sessions (token, profile_id, created_at, last_seen) VALUES (?,?,?,?)'
).run(token, profileId, now, now)
}
function getSession(token) {
const row = db.prepare('SELECT * FROM sessions WHERE token = ?').get(token)
if (!row) return null
db.prepare('UPDATE sessions SET last_seen = ? WHERE token = ?').run(Date.now(), token)
return row
}
function deleteSession(token) {
db.prepare('DELETE FROM sessions WHERE token = ?').run(token)
}
function getOrCreateProgress(profileId) {
const existing = db.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId)
if (existing) return existing
const now = Date.now()
db.prepare(
`INSERT INTO user_progress (profile_id, level, score, streak, best_streak,
total_correct, total_attempts, updated_at) VALUES (?,1,0,0,0,0,0,?)`
).run(profileId, now)
return db.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId)
}
function updateLevel(profileId, level) {
db.prepare('UPDATE user_progress SET level = ?, updated_at = ? WHERE profile_id = ?').run(
level, Date.now(), profileId
)
}
function recordAnswer(profileId, letter, correct) {
const progress = getOrCreateProgress(profileId)
const now = Date.now()
const isFirstToday = !progress.last_session_at ||
new Date(progress.last_session_at).toDateString() !== new Date(now).toDateString()
const base = progress.level * 10
const newStreak = correct ? progress.streak + 1 : 0
const streakMult = newStreak >= 10 ? 2 : 1
const dailyMult = isFirstToday && correct ? 1.25 : 1
const points = correct ? Math.floor(base * streakMult * dailyMult) : 0
const newBestStreak = Math.max(progress.best_streak, newStreak)
db.prepare(
`UPDATE user_progress SET
score = score + ?,
streak = ?,
best_streak = ?,
total_correct = total_correct + ?,
total_attempts = total_attempts + 1,
last_session_at = ?,
updated_at = ?
WHERE profile_id = ?`
).run(points, newStreak, newBestStreak, correct ? 1 : 0, now, now, profileId)
// Update letter stats
const existing = db
.prepare('SELECT * FROM letter_stats WHERE profile_id = ? AND letter = ?')
.get(profileId, letter)
if (existing) {
db.prepare(
'UPDATE letter_stats SET correct = correct + ?, attempts = attempts + 1, updated_at = ? WHERE profile_id = ? AND letter = ?'
).run(correct ? 1 : 0, now, profileId, letter)
} else {
db.prepare(
'INSERT INTO letter_stats (profile_id, letter, correct, attempts, updated_at) VALUES (?,?,?,1,?)'
).run(profileId, letter, correct ? 1 : 0, now)
}
return db.prepare('SELECT * FROM user_progress WHERE profile_id = ?').get(profileId)
}
function getMnemonics(profileId) {
const rows = db
.prepare('SELECT letter, phrase FROM user_mnemonics WHERE profile_id = ?')
.all(profileId)
return Object.fromEntries(rows.map(r => [r.letter, r.phrase]))
}
function saveMnemonic(profileId, letter, phrase) {
const now = Date.now()
const upperLetter = letter.toUpperCase()
const existing = db.prepare(
'SELECT 1 FROM user_mnemonics WHERE profile_id = ? AND letter = ?'
).get(profileId, upperLetter)
if (existing) {
db.prepare('UPDATE user_mnemonics SET phrase = ? WHERE profile_id = ? AND letter = ?')
.run(phrase, profileId, upperLetter)
} else {
db.prepare(
'INSERT INTO user_mnemonics (profile_id, letter, phrase, created_at) VALUES (?,?,?,?)'
).run(profileId, upperLetter, phrase, now)
// Award 50 points for first mnemonic save — ensure progress row exists first
getOrCreateProgress(profileId)
db.prepare('UPDATE user_progress SET score = score + 50, updated_at = ? WHERE profile_id = ?')
.run(now, profileId)
}
}
function getLetterStats(profileId) {
const rows = db
.prepare('SELECT letter, correct, attempts FROM letter_stats WHERE profile_id = ?')
.all(profileId)
return Object.fromEntries(rows.map(r => [r.letter, { correct: r.correct, attempts: r.attempts }]))
}
function getAdminUsers() {
return db.prepare(`
SELECT p.id, p.email, p.display_name, p.created_at, p.last_seen,
pr.level, pr.score, pr.streak, pr.best_streak,
pr.total_correct, pr.total_attempts
FROM profiles p
LEFT JOIN user_progress pr ON pr.profile_id = p.id
ORDER BY p.created_at DESC
`).all()
}
function getAdminStats() {
const totalUsers = db.prepare('SELECT COUNT(*) as c FROM profiles').get().c
const avgScore = db.prepare('SELECT AVG(score) as a FROM user_progress').get().a || 0
const mostMissed = db.prepare(`
SELECT letter, SUM(attempts - correct) as misses
FROM letter_stats GROUP BY letter ORDER BY misses DESC LIMIT 5
`).all()
return { totalUsers, avgScore: Math.round(avgScore), mostMissed }
}
module.exports = { initDb }
+142
View File
@@ -0,0 +1,142 @@
'use strict'
const { test, describe, before, after } = require('node:test')
const assert = require('node:assert/strict')
const { initDb } = require('../db.js')
let db
before(() => {
db = initDb(':memory:')
})
after(() => {
db._db.close()
})
describe('profiles', () => {
test('createProfile creates a profile', () => {
const p = db.createProfile('test@example.com', 'Tester')
assert.equal(p.email, 'test@example.com')
assert.equal(p.display_name, 'Tester')
assert.ok(p.id)
})
test('findProfileByEmail returns the profile', () => {
const p = db.findProfileByEmail('test@example.com')
assert.equal(p.email, 'test@example.com')
})
test('findProfileByEmail returns null for unknown email', () => {
assert.equal(db.findProfileByEmail('nobody@example.com'), null)
})
})
describe('magic tokens', () => {
let profileId
before(() => {
profileId = db.createProfile('magic@example.com', 'Magic').id
})
test('createMagicToken and useMagicToken returns profileId', () => {
const expires = Date.now() + 86400000
db.createMagicToken('tok123', profileId, expires)
const result = db.useMagicToken('tok123')
assert.equal(result, profileId)
})
test('useMagicToken returns null for used token', () => {
assert.equal(db.useMagicToken('tok123'), null)
})
test('useMagicToken returns null for expired token', () => {
db.createMagicToken('expiredtok', profileId, Date.now() - 1000)
assert.equal(db.useMagicToken('expiredtok'), null)
})
})
describe('sessions', () => {
let profileId
before(() => {
profileId = db.createProfile('session@example.com', 'Session').id
})
test('createSession and getSession returns profile_id', () => {
db.createSession('sess1', profileId)
const s = db.getSession('sess1')
assert.equal(s.profile_id, profileId)
})
test('getSession returns null for unknown token', () => {
assert.equal(db.getSession('unknown'), null)
})
test('deleteSession removes session', () => {
db.deleteSession('sess1')
assert.equal(db.getSession('sess1'), null)
})
})
describe('progress', () => {
let profileId
before(() => {
profileId = db.createProfile('progress@example.com', 'Progress').id
})
test('getOrCreateProgress returns default progress', () => {
const p = db.getOrCreateProgress(profileId)
assert.equal(p.level, 1)
assert.equal(p.score, 0)
assert.equal(p.streak, 0)
})
test('recordAnswer increments score and streak on correct', () => {
db.recordAnswer(profileId, 'A', true)
const p = db.getOrCreateProgress(profileId)
assert.ok(p.score > 0)
assert.equal(p.streak, 1)
assert.equal(p.total_correct, 1)
assert.equal(p.total_attempts, 1)
})
test('recordAnswer resets streak on incorrect', () => {
db.recordAnswer(profileId, 'A', false)
const p = db.getOrCreateProgress(profileId)
assert.equal(p.streak, 0)
assert.equal(p.total_attempts, 2)
})
})
describe('mnemonics', () => {
let profileId
before(() => {
profileId = db.createProfile('mnemonic@example.com', 'Mnemonic').id
})
test('saveMnemonic and getMnemonics returns map', () => {
db.saveMnemonic(profileId, 'A', 'go WALKING')
db.saveMnemonic(profileId, 'E', 'go')
const m = db.getMnemonics(profileId)
assert.equal(m.A, 'go WALKING')
assert.equal(m.E, 'go')
})
})
describe('letter stats', () => {
let profileId
before(() => {
profileId = db.createProfile('stats@example.com', 'Stats').id
})
test('recordAnswer tracks letter stats', () => {
db.recordAnswer(profileId, 'S', true)
db.recordAnswer(profileId, 'S', false)
const stats = db.getLetterStats(profileId)
assert.equal(stats.S.correct, 1)
assert.equal(stats.S.attempts, 2)
})
})