Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3

Merged
jhalitaksoy merged 88 commits from develop into main 2026-10-17 03:23:38 +03:00
7 changed files with 307 additions and 2 deletions
Showing only changes of commit f6136be007 - Show all commits

View File

@ -21,6 +21,7 @@
"@types/ws": "^7.4.5", "@types/ws": "^7.4.5",
"cors": "^2.8.5", "cors": "^2.8.5",
"express": "^4.17.1", "express": "^4.17.1",
"iovalkey": "0.4.0",
"jose": "5.10.0", "jose": "5.10.0",
"mancala.js": "link:../../packages/mancala.js", "mancala.js": "link:../../packages/mancala.js",
"morgan": "^1.10.0", "morgan": "^1.10.0",

40
apps/backend/src/cache/Cache.ts vendored Normal file
View File

@ -0,0 +1,40 @@
import { Valkey } from "iovalkey";
const PREFIX = "mancala:cache:";
// Short-lived cache of JSON values. Never fails: errors count as a miss, so callers just compute the value.
export interface Cache {
getJson<T>(key: string): Promise<T | undefined>;
setJson(key: string, value: unknown, ttlSeconds: number): Promise<void>;
delete(...keys: string[]): Promise<void>;
}
export class ValkeyCache implements Cache {
constructor(private readonly client: Valkey) { }
async getJson<T>(key: string): Promise<T | undefined> {
try {
const value = await this.client.get(PREFIX + key);
return value === null ? undefined : JSON.parse(value) as T;
} catch (err) {
return undefined;
}
}
async setJson(key: string, value: unknown, ttlSeconds: number): Promise<void> {
try {
await this.client.set(PREFIX + key, JSON.stringify(value), "EX", ttlSeconds);
} catch (err) {
// A missing cache entry only costs a database query.
}
}
async delete(...keys: string[]): Promise<void> {
if (keys.length === 0) return;
try {
await this.client.del(...keys.map((key) => PREFIX + key));
} catch (err) {
// Entries expire on their own.
}
}
}

View File

@ -0,0 +1,61 @@
import { GameRecord } from "@mancala/core";
import { Valkey } from "iovalkey";
import { scanKeys } from "./valkey";
const LIVE_PREFIX = "mancala:live:";
const PENDING_SAVE_PREFIX = "mancala:pending-save:";
// Durable copy of state that otherwise only lives in memory: live games, so they survive a restart, and finished
// games that could not be saved to the database yet. Methods reject when Valkey is unreachable.
export interface LiveGameStore {
saveGame(gameId: string, snapshot: unknown): Promise<void>;
removeGame(gameId: string): Promise<void>;
loadGames(): Promise<unknown[]>;
savePendingRecord(record: GameRecord): Promise<void>;
removePendingRecord(gameId: string): Promise<void>;
loadPendingRecords(): Promise<GameRecord[]>;
}
export class ValkeyLiveGameStore implements LiveGameStore {
constructor(private readonly client: Valkey) { }
async saveGame(gameId: string, snapshot: unknown): Promise<void> {
await this.client.set(LIVE_PREFIX + gameId, JSON.stringify(snapshot));
}
async removeGame(gameId: string): Promise<void> {
await this.client.del(LIVE_PREFIX + gameId);
}
loadGames(): Promise<unknown[]> {
return this.loadAll(LIVE_PREFIX);
}
async savePendingRecord(record: GameRecord): Promise<void> {
await this.client.set(PENDING_SAVE_PREFIX + record.id, JSON.stringify(record));
}
async removePendingRecord(gameId: string): Promise<void> {
await this.client.del(PENDING_SAVE_PREFIX + gameId);
}
loadPendingRecords(): Promise<GameRecord[]> {
return this.loadAll(PENDING_SAVE_PREFIX) as Promise<GameRecord[]>;
}
private async loadAll(prefix: string): Promise<unknown[]> {
const keys = await scanKeys(this.client, prefix + "*");
if (keys.length === 0) return [];
const values = await this.client.mget(keys);
const parsed: unknown[] = [];
values.forEach((value, i) => {
if (value === null) return;
try {
parsed.push(JSON.parse(value));
} catch {
console.error(`(Valkey) Ignoring unreadable value in ${keys[i]}`);
}
});
return parsed;
}
}

View File

@ -0,0 +1,33 @@
import { RedisOptions, Valkey } from "iovalkey";
export function createValkeyClient(url: string, options: RedisOptions = {}): Valkey {
const client = new Valkey(url, {
// Fail commands at once while Valkey is down instead of queueing them; callers fall back.
enableOfflineQueue: false,
maxRetriesPerRequest: 1,
...options,
});
// Without a listener, connection errors would be unhandled 'error' events and crash the process.
let lastError = "";
client.on("error", (err: Error) => {
if (err.message !== lastError) console.error("(Valkey) " + err.message);
lastError = err.message;
});
client.on("ready", () => {
lastError = "";
console.info("(Valkey) Connected");
});
return client;
}
// Keys matching the pattern, without blocking Valkey the way KEYS would.
export async function scanKeys(client: Valkey, pattern: string): Promise<string[]> {
const keys: string[] = [];
let cursor = "0";
do {
const [next, batch] = await client.scan(cursor, "MATCH", pattern, "COUNT", 100);
keys.push(...batch);
cursor = next;
} while (cursor !== "0");
return keys;
}

View File

@ -0,0 +1,113 @@
import { GameRecord } from "@mancala/core";
import { Valkey } from "iovalkey";
import { ValkeyCache } from "../../src/cache/Cache";
import { ValkeyLiveGameStore } from "../../src/live/LiveGameStore";
import { createValkeyClient } from "../../src/live/valkey";
import { RunningService, startValkey } from "../support/containers";
jest.setTimeout(120_000);
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
const record = (id: string): GameRecord => ({
id,
player1: { userId: "kc:alice", name: "alice", isGuest: false },
player2: { userId: "kc:bob", name: "bob", isGuest: false },
board: [0, 0, 0, 0, 0, 0, 30, 0, 0, 0, 0, 0, 0, 18],
result: "player1", endReason: "completed",
startedAt: 1, endedAt: 2, moveCount: 0, peakSpectators: 0, totalSpectators: 0, moves: [],
});
describe("Valkey store and cache", () => {
let valkey: RunningService;
let client: Valkey;
beforeAll(async () => {
valkey = await startValkey();
client = createValkeyClient(valkey.url);
await new Promise((resolve) => client.once("ready", resolve));
});
afterAll(async () => {
client?.disconnect();
await valkey?.container.stop();
});
beforeEach(() => client.flushall());
describe("ValkeyLiveGameStore", () => {
it("saves, loads and removes live game snapshots", async () => {
const store = new ValkeyLiveGameStore(client);
await store.saveGame("g1", { id: "g1", moves: 1 });
await store.saveGame("g2", { id: "g2", moves: 2 });
await store.saveGame("g1", { id: "g1", moves: 3 });
const sortById = (games: unknown[]) => (games as { id: string }[]).sort((a, b) => a.id.localeCompare(b.id));
expect(sortById(await store.loadGames())).toEqual([{ id: "g1", moves: 3 }, { id: "g2", moves: 2 }]);
await store.removeGame("g1");
expect(await store.loadGames()).toEqual([{ id: "g2", moves: 2 }]);
});
it("keeps pending records separate from live games", async () => {
const store = new ValkeyLiveGameStore(client);
await store.saveGame("g1", { id: "g1" });
await store.savePendingRecord(record("r1"));
expect(await store.loadPendingRecords()).toEqual([record("r1")]);
expect(await store.loadGames()).toEqual([{ id: "g1" }]);
await store.removePendingRecord("r1");
expect(await store.loadPendingRecords()).toEqual([]);
});
it("skips values it cannot read", async () => {
const store = new ValkeyLiveGameStore(client);
await client.set("mancala:live:broken", "{not json");
await store.saveGame("g1", { id: "g1" });
expect(await store.loadGames()).toEqual([{ id: "g1" }]);
});
});
describe("ValkeyCache", () => {
it("returns cached values until they expire", async () => {
const cache = new ValkeyCache(client);
await cache.setJson("popular", [1, 2, 3], 1);
expect(await cache.getJson("popular")).toEqual([1, 2, 3]);
await sleep(1500);
expect(await cache.getJson("popular")).toBeUndefined();
});
it("deletes entries", async () => {
const cache = new ValkeyCache(client);
await cache.setJson("a", 1, 60);
await cache.setJson("b", 2, 60);
await cache.delete("a", "b");
expect(await cache.getJson("a")).toBeUndefined();
expect(await cache.getJson("b")).toBeUndefined();
});
});
describe("when Valkey is unreachable", () => {
let offline: Valkey;
beforeAll(() => {
// Nothing listens on port 1. No reconnect attempts, so nothing is left running after the tests.
offline = createValkeyClient("redis://127.0.0.1:1", { retryStrategy: () => null, disconnectTimeout: 50 });
});
afterAll(() => offline.disconnect());
it("the cache answers with misses instead of failing", async () => {
const cache = new ValkeyCache(offline);
await expect(cache.setJson("a", 1, 60)).resolves.toBeUndefined();
await expect(cache.getJson("a")).resolves.toBeUndefined();
await expect(cache.delete("a")).resolves.toBeUndefined();
});
it("the store rejects at once, so callers can fall back", async () => {
const store = new ValkeyLiveGameStore(offline);
const started = Date.now();
await expect(store.saveGame("g1", {})).rejects.toThrow();
expect(Date.now() - started).toBeLessThan(1000);
});
});
});

View File

@ -331,6 +331,11 @@
protobufjs "^7.5.5" protobufjs "^7.5.5"
yargs "^17.7.2" yargs "^17.7.2"
"@iovalkey/commands@^0.1.0":
version "0.1.0"
resolved "https://registry.yarnpkg.com/@iovalkey/commands/-/commands-0.1.0.tgz#62648b19106690ee8ce7d11aaf6249a242f927b0"
integrity sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==
"@isaacs/cliui@^8.0.2": "@isaacs/cliui@^8.0.2":
version "8.0.2" version "8.0.2"
resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550"
@ -1518,6 +1523,11 @@ clone-response@^1.0.2:
dependencies: dependencies:
mimic-response "^1.0.0" mimic-response "^1.0.0"
cluster-key-slot@^1.1.0:
version "1.1.2"
resolved "https://registry.yarnpkg.com/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz#88ddaa46906e303b5de30d3153b7d9fe0a0c19ac"
integrity sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==
co@^4.6.0: co@^4.6.0:
version "4.6.0" version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@ -1725,7 +1735,7 @@ debug@^3.2.7:
dependencies: dependencies:
ms "^2.1.1" ms "^2.1.1"
debug@^4.4.3: debug@^4.3.4, debug@^4.4.3:
version "4.4.3" version "4.4.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
@ -1774,6 +1784,11 @@ delayed-stream@~1.0.0:
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
denque@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1"
integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==
depd@2.0.0, depd@~2.0.0: depd@2.0.0, depd@~2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df"
@ -2399,6 +2414,21 @@ ini@~1.3.0:
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c"
integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==
iovalkey@0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/iovalkey/-/iovalkey-0.4.0.tgz#65316e7aabc980191f2122709569ec878b880d3b"
integrity sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==
dependencies:
"@iovalkey/commands" "^0.1.0"
cluster-key-slot "^1.1.0"
debug "^4.3.4"
denque "^2.1.0"
lodash.defaults "^4.2.0"
lodash.isarguments "^3.1.0"
redis-errors "^1.2.0"
redis-parser "^3.0.0"
standard-as-callback "^2.1.0"
ipaddr.js@1.9.1: ipaddr.js@1.9.1:
version "1.9.1" version "1.9.1"
resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3"
@ -3108,6 +3138,16 @@ lodash.camelcase@^4.3.0:
resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6" resolved "https://registry.yarnpkg.com/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz#b28aa6288a2b9fc651035c7711f65ab6190331a6"
integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA== integrity sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==
lodash.defaults@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/lodash.defaults/-/lodash.defaults-4.2.0.tgz#d09178716ffea4dde9e5fb7b37f6f0802274580c"
integrity sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==
lodash.isarguments@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz#2f573d85c6a24289ff00663b491c1d338ff3458a"
integrity sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==
lodash.memoize@4.x: lodash.memoize@4.x:
version "4.1.2" version "4.1.2"
resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe"
@ -3815,6 +3855,18 @@ readdirp@~3.6.0:
dependencies: dependencies:
picomatch "^2.2.1" picomatch "^2.2.1"
redis-errors@^1.0.0, redis-errors@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/redis-errors/-/redis-errors-1.2.0.tgz#eb62d2adb15e4eaf4610c04afe1529384250abad"
integrity sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==
redis-parser@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/redis-parser/-/redis-parser-3.0.0.tgz#b66d828cdcafe6b4b8a428a7def4c6bcac31c8b4"
integrity sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==
dependencies:
redis-errors "^1.0.0"
registry-auth-token@^4.0.0: registry-auth-token@^4.0.0:
version "4.2.1" version "4.2.1"
resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.1.tgz#6d7b4006441918972ccd5fedcd41dc322c79b250" resolved "https://registry.yarnpkg.com/registry-auth-token/-/registry-auth-token-4.2.1.tgz#6d7b4006441918972ccd5fedcd41dc322c79b250"
@ -4059,6 +4111,11 @@ stack-utils@^2.0.3:
dependencies: dependencies:
escape-string-regexp "^2.0.0" escape-string-regexp "^2.0.0"
standard-as-callback@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/standard-as-callback/-/standard-as-callback-2.1.0.tgz#8953fc05359868a77b5b9739a665c5977bb7df45"
integrity sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==
statuses@2.0.1: statuses@2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63"

View File

@ -11,7 +11,7 @@ Work through the tasks in order; each ends with its tests passing and a commit.
info once at game start. info once at game start.
- [x] 4. **Postgres repository**: pool, migration runner, `games` and `game_players`, `PostgresGameHistoryRepository` - [x] 4. **Postgres repository**: pool, migration runner, `games` and `game_players`, `PostgresGameHistoryRepository`
(save, get, recent for user, popular), integration tests. (save, get, recent for user, popular), integration tests.
- [ ] 5. **Valkey store**: live game snapshots and restore, pending saves, cache; integration tests. - [x] 5. **Valkey store**: live game snapshots and restore, pending saves, cache; integration tests.
- [ ] 6. **Game lifecycle**: start and move times, spectator counters, outcomes (completed, left, timeout, inactive), - [ ] 6. **Game lifecycle**: start and move times, spectator counters, outcomes (completed, left, timeout, inactive),
timeout watcher, `offlineSince`, rejecting a new game while playing, saving with retry, restore on startup; timeout watcher, `offlineSince`, rejecting a new game while playing, saving with retry, restore on startup;
unit tests. unit tests.