feat: migrate runtime backend to node server

This commit is contained in:
victo
2026-04-08 16:41:29 +08:00
parent 9d2fc9e4b8
commit a83841ff2d
70 changed files with 8239 additions and 1561 deletions

View File

@@ -0,0 +1,182 @@
import type { AppDatabase } from '../db.js';
const SAVE_SNAPSHOT_VERSION = 2;
const DEFAULT_MUSIC_VOLUME = 0.42;
const MAX_CUSTOM_WORLD_PROFILES = 12;
export type SavedSnapshot = {
version: number;
savedAt: string;
gameState: unknown;
bottomTab: string;
currentStory: unknown;
};
export type RuntimeSettings = {
musicVolume: number;
};
function parseJson<T>(value: string): T {
return JSON.parse(value) as T;
}
function toJson(value: unknown) {
return JSON.stringify(value ?? null);
}
export class RuntimeRepository {
constructor(private readonly db: AppDatabase) {}
getSnapshot(userId: string) {
const row = this.db
.prepare(
`SELECT version, saved_at, game_state_json, bottom_tab, current_story_json
FROM save_snapshots
WHERE user_id = ?`,
)
.get(userId) as
| {
version: number;
saved_at: string;
game_state_json: string;
bottom_tab: string;
current_story_json: string;
}
| undefined;
if (!row) {
return null;
}
return {
version: row.version,
savedAt: row.saved_at,
gameState: parseJson(row.game_state_json),
bottomTab: row.bottom_tab,
currentStory: parseJson(row.current_story_json),
} satisfies SavedSnapshot;
}
putSnapshot(userId: string, payload: Omit<SavedSnapshot, 'version'>) {
const snapshot = {
version: SAVE_SNAPSHOT_VERSION,
savedAt: payload.savedAt,
gameState: payload.gameState,
bottomTab: payload.bottomTab,
currentStory: payload.currentStory,
} satisfies SavedSnapshot;
const now = new Date().toISOString();
this.db
.prepare(
`INSERT INTO save_snapshots (
user_id, version, saved_at, bottom_tab, game_state_json, current_story_json, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
version = excluded.version,
saved_at = excluded.saved_at,
bottom_tab = excluded.bottom_tab,
game_state_json = excluded.game_state_json,
current_story_json = excluded.current_story_json,
updated_at = excluded.updated_at`,
)
.run(
userId,
snapshot.version,
snapshot.savedAt,
snapshot.bottomTab,
toJson(snapshot.gameState),
toJson(snapshot.currentStory),
now,
);
return snapshot;
}
deleteSnapshot(userId: string) {
this.db.prepare(`DELETE FROM save_snapshots WHERE user_id = ?`).run(userId);
}
getSettings(userId: string) {
const row = this.db
.prepare(
`SELECT music_volume
FROM runtime_settings
WHERE user_id = ?`,
)
.get(userId) as { music_volume: number } | undefined;
return {
musicVolume:
typeof row?.music_volume === 'number'
? row.music_volume
: DEFAULT_MUSIC_VOLUME,
} satisfies RuntimeSettings;
}
putSettings(userId: string, settings: RuntimeSettings) {
const nextSettings = {
musicVolume: Math.max(0, Math.min(1, settings.musicVolume)),
} satisfies RuntimeSettings;
this.db
.prepare(
`INSERT INTO runtime_settings (user_id, music_volume, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
music_volume = excluded.music_volume,
updated_at = excluded.updated_at`,
)
.run(userId, nextSettings.musicVolume, new Date().toISOString());
return nextSettings;
}
listCustomWorldProfiles(userId: string) {
const rows = this.db
.prepare(
`SELECT payload_json
FROM custom_world_profiles
WHERE user_id = ?
ORDER BY updated_at DESC
LIMIT ?`,
)
.all(userId, MAX_CUSTOM_WORLD_PROFILES) as Array<{ payload_json: string }>;
return rows.map((row) => parseJson<Record<string, unknown>>(row.payload_json));
}
upsertCustomWorldProfile(
userId: string,
profileId: string,
profile: Record<string, unknown>,
) {
const payload = {
...profile,
id: profileId,
};
this.db
.prepare(
`INSERT INTO custom_world_profiles (user_id, profile_id, payload_json, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(user_id, profile_id) DO UPDATE SET
payload_json = excluded.payload_json,
updated_at = excluded.updated_at`,
)
.run(userId, profileId, JSON.stringify(payload), new Date().toISOString());
return this.listCustomWorldProfiles(userId);
}
deleteCustomWorldProfile(userId: string, profileId: string) {
this.db
.prepare(
`DELETE FROM custom_world_profiles
WHERE user_id = ? AND profile_id = ?`,
)
.run(userId, profileId);
return this.listCustomWorldProfiles(userId);
}
}

View File

@@ -0,0 +1,88 @@
import crypto from 'node:crypto';
import type { AppDatabase } from '../db.js';
export type UserRecord = {
id: string;
username: string;
passwordHash: string;
tokenVersion: number;
createdAt: string;
updatedAt: string;
};
type UserRow = {
id: string;
username: string;
password_hash: string;
token_version: number;
created_at: string;
updated_at: string;
};
function toUserRecord(row: UserRow | undefined): UserRecord | null {
if (!row) {
return null;
}
return {
id: row.id,
username: row.username,
passwordHash: row.password_hash,
tokenVersion: row.token_version,
createdAt: row.created_at,
updatedAt: row.updated_at,
};
}
export class UserRepository {
constructor(private readonly db: AppDatabase) {}
findByUsername(username: string) {
const row = this.db
.prepare(
`SELECT id, username, password_hash, token_version, created_at, updated_at
FROM users
WHERE username = ?`,
)
.get(username) as UserRow | undefined;
return toUserRecord(row);
}
findById(userId: string) {
const row = this.db
.prepare(
`SELECT id, username, password_hash, token_version, created_at, updated_at
FROM users
WHERE id = ?`,
)
.get(userId) as UserRow | undefined;
return toUserRecord(row);
}
create(username: string, passwordHash: string) {
const now = new Date().toISOString();
const id = `user_${crypto.randomBytes(16).toString('hex')}`;
this.db
.prepare(
`INSERT INTO users (id, username, password_hash, token_version, created_at, updated_at)
VALUES (?, ?, ?, 1, ?, ?)`,
)
.run(id, username, passwordHash, now, now);
return this.findById(id);
}
incrementTokenVersion(userId: string) {
this.db
.prepare(
`UPDATE users
SET token_version = token_version + 1, updated_at = ?
WHERE id = ?`,
)
.run(new Date().toISOString(), userId);
return this.findById(userId);
}
}