feat(saves): store game saves on the player's account

Adds game_save_snapshots and /api/game-saves for copies of the saves games
keep in a player's browser. An upload matching the current save is a no-op,
the newest 10 snapshots per user and game are kept as backups, and saves may
be up to 10 MB, so the router parses its own bodies past the app's 1 MB JSON
limit.
This commit is contained in:
2026-10-12 16:17:09 -07:00
parent 62169a4ff7
commit 0980b789d5
6 changed files with 325 additions and 1 deletions
+21
View File
@@ -0,0 +1,21 @@
-- ===================================
-- Migration 008: Game saves
-- ===================================
-- Copies of the saves games keep in a player's browser, so a fresh browser can
-- restore them. Each row is one snapshot; the newest few per user and game are
-- kept as backups.
CREATE TABLE IF NOT EXISTS game_save_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
game_id TEXT NOT NULL,
content_hash TEXT NOT NULL,
data TEXT NOT NULL,
size INTEGER NOT NULL,
saved_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_game_save_snapshots_user_game
ON game_save_snapshots(user_id, game_id, id DESC);
+121
View File
@@ -0,0 +1,121 @@
import { describe, it, expect, vi, beforeAll, afterAll } from 'vitest';
import express, { Express } from 'express';
import cookieParser from 'cookie-parser';
import request from 'supertest';
import fs from 'fs';
vi.mock('../utils/logger', () => ({
logger: { info: vi.fn(), error: vi.fn(), debug: vi.fn(), warn: vi.fn() },
}));
// UserDatabaseService creates the directory
const tempDir = vi.hoisted(
() => `${process.env.TMPDIR ?? '/tmp'}/game-saves-${process.pid}-${Date.now()}`
);
vi.mock('../config', async (importOriginal) => {
const actual = await importOriginal<typeof import('../config')>();
return { config: { ...actual.config, userDbPath: `${tempDir}/user.db` } };
});
import gameSavesRouter from './game-saves';
import { errorHandler } from '../middleware/errorHandler';
import { UserDatabaseService } from '../services/UserDatabaseService';
import { AuthService } from '../services/AuthService';
const GAME_ID = '247a4c93-c252-49c8-8bc9-fb43d5c039df';
describe('game saves API', () => {
let app: Express;
let alice: string;
let bob: string;
beforeAll(async () => {
await UserDatabaseService.initialize();
const authService = new AuthService();
alice = (await authService.trustedLogin('alice', null)).tokens.accessToken;
bob = (await authService.trustedLogin('bobby', null)).tokens.accessToken;
app = express();
app.use(cookieParser());
app.use('/api/game-saves', gameSavesRouter);
app.use(errorHandler);
});
afterAll(() => {
UserDatabaseService.close();
fs.rmSync(tempDir, { recursive: true, force: true });
});
const save = (token: string, hash: string, data: object = { localStorage: { k: hash } }) =>
request(app)
.post(`/api/game-saves/${GAME_ID}`)
.set('Authorization', `Bearer ${token}`)
.send({ data, hash, savedAt: '2026-10-12T10:00:00.000Z' });
const fetchSaves = (token: string) =>
request(app).get(`/api/game-saves/${GAME_ID}`).set('Authorization', `Bearer ${token}`);
it('starts empty and requires a signed-in user', async () => {
expect((await request(app).get(`/api/game-saves/${GAME_ID}`)).status).toBe(401);
expect((await fetchSaves(alice)).body).toEqual({ latest: null, snapshots: [] });
});
it('stores saves, skipping one identical to the current save', async () => {
const first = await save(alice, 'h1');
expect(first.status).toBe(201);
expect((await save(alice, 'h1')).status).toBe(200);
expect((await save(alice, 'h2')).status).toBe(201);
const { body } = await fetchSaves(alice);
expect(body.latest).toMatchObject({ hash: 'h2', data: { localStorage: { k: 'h2' } } });
expect(body.snapshots.map((s: { hash: string }) => s.hash)).toEqual(['h2', 'h1']);
const older = await request(app)
.get(`/api/game-saves/${GAME_ID}/${first.body.id}`)
.set('Authorization', `Bearer ${alice}`);
expect(older.body).toMatchObject({ hash: 'h1', data: { localStorage: { k: 'h1' } } });
});
it('keeps only the newest 10 saves', async () => {
for (let i = 3; i <= 14; i++) {
await save(alice, `h${i}`);
}
const { body } = await fetchSaves(alice);
expect(body.snapshots).toHaveLength(10);
expect(body.snapshots[0].hash).toBe('h14');
expect(body.snapshots[9].hash).toBe('h5');
});
it("keeps each user's saves separate", async () => {
expect((await fetchSaves(bob)).body.latest).toBeNull();
const aliceSnapshot = (await fetchSaves(alice)).body.latest.id;
const res = await request(app)
.get(`/api/game-saves/${GAME_ID}/${aliceSnapshot}`)
.set('Authorization', `Bearer ${bob}`);
expect(res.status).toBe(404);
});
it('accepts saves over the app-wide 1 MB body limit, and clamps future timestamps', async () => {
const big = { indexedDB: 'x'.repeat(2 * 1024 * 1024) };
const res = await request(app)
.post(`/api/game-saves/${GAME_ID}`)
.set('Authorization', `Bearer ${bob}`)
.send({ data: big, hash: 'big', savedAt: '2999-01-01T00:00:00.000Z' });
expect(res.status).toBe(201);
expect(res.body.size).toBeGreaterThan(2 * 1024 * 1024);
expect(Date.parse(res.body.savedAt)).toBeLessThanOrEqual(Date.now());
});
it('rejects invalid input', async () => {
const bad = await request(app)
.post('/api/game-saves/not-a-game')
.set('Authorization', `Bearer ${alice}`)
.send({ data: {}, hash: 'x', savedAt: '2026-10-12T10:00:00.000Z' });
expect(bad.status).toBe(400);
const noHash = await request(app)
.post(`/api/game-saves/${GAME_ID}`)
.set('Authorization', `Bearer ${alice}`)
.send({ data: {}, savedAt: '2026-10-12T10:00:00.000Z' });
expect(noHash.status).toBe(400);
});
});
+74
View File
@@ -0,0 +1,74 @@
import express, { Router } from 'express';
import { z } from 'zod';
import { authenticate } from '../middleware/auth';
import { requirePermission } from '../middleware/rbac';
import { AppError } from '../middleware/errorHandler';
import { asyncHandler } from '../middleware/asyncHandler';
import { GameSaveService } from '../services/GameSaveService';
const router = Router();
const gameSaveService = new GameSaveService();
/** Saves can outgrow the app's 1 MB JSON limit; server.ts leaves parsing to this router. */
export const GAME_SAVE_BODY_LIMIT = '10mb';
const gameIdSchema = z.string().uuid('Invalid game ID format');
const saveSchema = z.object({
data: z.record(z.unknown()),
hash: z.string().min(1).max(64),
savedAt: z.string().datetime(),
});
router.use(express.json({ limit: GAME_SAVE_BODY_LIMIT }));
router.use(authenticate, requirePermission('games.play'));
function userId(req: express.Request): number {
if (!req.user) {
throw new AppError(401, 'Authentication required');
}
return req.user.id;
}
// The current save with its data, and every kept snapshot's metadata
router.get(
'/:gameId',
asyncHandler(async (req, res) => {
const gameId = gameIdSchema.parse(req.params.gameId);
const id = userId(req);
res.json({
latest: gameSaveService.get(id, gameId),
snapshots: gameSaveService.list(id, gameId),
});
})
);
router.get(
'/:gameId/:snapshotId',
asyncHandler(async (req, res) => {
const gameId = gameIdSchema.parse(req.params.gameId);
const snapshotId = parseInt(req.params.snapshotId, 10);
if (isNaN(snapshotId)) {
throw new AppError(400, 'Invalid snapshot ID');
}
const snapshot = gameSaveService.get(userId(req), gameId, snapshotId);
if (!snapshot) {
throw new AppError(404, 'Save not found');
}
res.json(snapshot);
})
);
router.post(
'/:gameId',
asyncHandler(async (req, res) => {
const gameId = gameIdSchema.parse(req.params.gameId);
const body = saveSchema.parse(req.body);
// A browser's clock can run ahead; a save can't be from the future
const savedAt = new Date(Math.min(Date.parse(body.savedAt), Date.now())).toISOString();
const result = gameSaveService.save(userId(req), gameId, body.data, body.hash, savedAt);
res.status(result.created ? 201 : 200).json(result.snapshot);
})
);
export default router;
+2
View File
@@ -22,6 +22,7 @@ import userPlaylistsRouter from './user-playlists';
import sharedPlaylistsRouter from './shared-playlists';
import favoritesRouter from './favorites';
import ratingsRouter from './ratings';
import gameSavesRouter from './game-saves';
import ruffleRouter from './ruffle';
import githubRouter from './github';
import domainsRouter from './domains';
@@ -49,6 +50,7 @@ export function setupRoutes(app: Express): void {
app.use('/api/playlists/shared', sharedPlaylistsRouter);
app.use('/api/favorites', favoritesRouter);
app.use('/api/ratings', ratingsRouter);
app.use('/api/game-saves', gameSavesRouter);
app.use('/api/games', gamesRouter);
app.use('/api/games', downloadsRouter);
app.use('/api/platforms', platformsRouter);
+5 -1
View File
@@ -209,7 +209,11 @@ async function startServer() {
})
);
app.use(express.json({ limit: '1mb' }));
// Game saves parse their own, larger bodies (routes/game-saves.ts)
const jsonParser = express.json({ limit: '1mb' });
app.use((req, res, next) =>
req.path.startsWith('/api/game-saves') ? next() : jsonParser(req, res, next)
);
app.use(express.urlencoded({ extended: true, limit: '1mb' }));
app.use(cookieParser());
+102
View File
@@ -0,0 +1,102 @@
import { UserDatabaseService } from './UserDatabaseService';
/** Snapshots kept per user and game, the newest being the current save. */
export const SNAPSHOTS_KEPT = 10;
export interface GameSaveSnapshotMeta {
id: number;
hash: string;
size: number;
savedAt: string;
createdAt: string;
}
export interface GameSaveSnapshot extends GameSaveSnapshotMeta {
data: unknown;
}
interface SnapshotRow {
id: number;
content_hash: string;
size: number;
saved_at: string;
created_at: string;
data?: string;
}
function toMeta(row: SnapshotRow): GameSaveSnapshotMeta {
return {
id: row.id,
hash: row.content_hash,
size: row.size,
savedAt: row.saved_at,
createdAt: row.created_at,
};
}
/** Stores copies of the saves games keep in a player's browser. */
export class GameSaveService {
/** All snapshots for a game, newest first. */
list(userId: number, gameId: string): GameSaveSnapshotMeta[] {
return UserDatabaseService.all<SnapshotRow>(
`SELECT id, content_hash, size, saved_at, created_at FROM game_save_snapshots
WHERE user_id = ? AND game_id = ? ORDER BY id DESC`,
[userId, gameId]
).map(toMeta);
}
get(userId: number, gameId: string, id?: number): GameSaveSnapshot | null {
const row = UserDatabaseService.get<SnapshotRow>(
`SELECT id, content_hash, size, saved_at, created_at, data FROM game_save_snapshots
WHERE user_id = ? AND game_id = ? ${id === undefined ? '' : 'AND id = ?'}
ORDER BY id DESC LIMIT 1`,
id === undefined ? [userId, gameId] : [userId, gameId, id]
);
return row ? { ...toMeta(row), data: JSON.parse(row.data ?? 'null') } : null;
}
/**
* Stores a snapshot as the game's current save, unless it matches the current
* one, then drops snapshots beyond the newest SNAPSHOTS_KEPT.
*/
save(
userId: number,
gameId: string,
data: unknown,
hash: string,
savedAt: string
): { snapshot: GameSaveSnapshotMeta; created: boolean } {
const json = JSON.stringify(data);
const db = UserDatabaseService.getDatabase();
return db.transaction(() => {
const latest = db
.prepare(
`SELECT id, content_hash, size, saved_at, created_at FROM game_save_snapshots
WHERE user_id = ? AND game_id = ? ORDER BY id DESC LIMIT 1`
)
.get(userId, gameId) as SnapshotRow | undefined;
if (latest?.content_hash === hash) {
return { snapshot: toMeta(latest), created: false };
}
const result = db
.prepare(
`INSERT INTO game_save_snapshots (user_id, game_id, content_hash, data, size, saved_at)
VALUES (?, ?, ?, ?, ?, ?)`
)
.run(userId, gameId, hash, json, Buffer.byteLength(json), savedAt);
db.prepare(
`DELETE FROM game_save_snapshots WHERE user_id = ? AND game_id = ? AND id NOT IN (
SELECT id FROM game_save_snapshots WHERE user_id = ? AND game_id = ?
ORDER BY id DESC LIMIT ?)`
).run(userId, gameId, userId, gameId, SNAPSHOTS_KEPT);
const row = db
.prepare(
`SELECT id, content_hash, size, saved_at, created_at FROM game_save_snapshots WHERE id = ?`
)
.get(result.lastInsertRowid) as SnapshotRow;
return { snapshot: toMeta(row), created: true };
})();
}
}