import Dexie, { Table } from "dexie"; import { GameSession, AppSettings, SavedPlayer, Location, Profile, SyncState, } from "../types"; import { generateId } from "../utils/id"; // When the sync engine applies records pulled/acked from the server, it flips // this flag so the auto-dirty hooks don't re-mark those writes as dirty. export const remoteApply = { active: false }; // The sync engine registers a (debounced) callback here so any local change to // a synced table promptly schedules a push. Kept as a hook to avoid a circular // import between db.ts and syncEngine.ts. export const localChange = { notify: null as null | (() => void) }; export class BoardScoreDatabase extends Dexie { sessions!: Table; settings!: Table; players!: Table; locations!: Table; profiles!: Table; syncState!: Table; constructor() { super("BoardScoreDatabase"); this.version(1).stores({ sessions: "id, gameId, dateStart, status", settings: "id", }); // Version 2: Added players table this.version(2).stores({ sessions: "id, gameId, dateStart, status", settings: "id", players: "id, name, createdAt", }); // Version 3: Added locations table, updatedAt tracking for future sync this.version(3) .stores({ sessions: "id, gameId, dateStart, status, locationId, updatedAt", settings: "id", players: "id, name, createdAt, updatedAt", locations: "id, name, createdAt, updatedAt", }) .upgrade(async (tx) => { await tx .table("sessions") .toCollection() .modify((session) => { session.updatedAt = session.dateEnd ?? session.dateStart; }); await tx .table("players") .toCollection() .modify((player) => { player.updatedAt = player.createdAt; }); }); // Version 4: Added local profiles; scope existing data to a default profile this.version(4) .stores({ sessions: "id, gameId, dateStart, status, locationId, updatedAt, profileId", settings: "id", players: "id, name, createdAt, updatedAt, profileId", locations: "id, name, createdAt, updatedAt, profileId", profiles: "id, name, createdAt, updatedAt", }) .upgrade(async (tx) => { const now = Date.now(); const defaultProfileId = generateId(); await tx.table("profiles").add({ id: defaultProfileId, name: "Moi", createdAt: now, updatedAt: now, }); await tx .table("sessions") .toCollection() .modify((session) => { session.profileId = defaultProfileId; }); await tx .table("players") .toCollection() .modify((player) => { player.profileId = defaultProfileId; }); await tx .table("locations") .toCollection() .modify((location) => { location.profileId = defaultProfileId; }); const settings = await tx.table("settings").get(1); if (settings) { await tx .table("settings") .update(1, { activeProfileId: defaultProfileId }); } else { await tx.table("settings").add({ id: 1, theme: "system", language: "fr", activeProfileId: defaultProfileId, }); } }); // Version 5: Sync bookkeeping (dirty flag + sync cursor per profile) this.version(5) .stores({ sessions: "id, gameId, dateStart, status, locationId, updatedAt, profileId, dirty", settings: "id", players: "id, name, createdAt, updatedAt, profileId, dirty", locations: "id, name, createdAt, updatedAt, profileId, dirty", profiles: "id, name, createdAt, updatedAt", syncState: "profileId", }) .upgrade(async (tx) => { // Mark all pre-existing records dirty so they push on the first sync. for (const table of ["sessions", "players", "locations"]) { await tx .table(table) .toCollection() .modify((row) => { row.dirty = 1; }); } }); } } export const db = new BoardScoreDatabase(); // ---- Auto-stamp updatedAt + dirty on every local write to synced tables ---- // Skipped while the sync engine is applying server data (remoteApply.active). for (const table of [db.sessions, db.players, db.locations]) { table.hook("creating", (_primKey, obj: any) => { if (remoteApply.active) return; if (obj.updatedAt === undefined) obj.updatedAt = Date.now(); if (obj.dirty === undefined) obj.dirty = 1; localChange.notify?.(); }); table.hook("updating", (modifications: any) => { if (remoteApply.active) return; // Don't clobber an explicit dirty/updatedAt already in this update. const extra: Record = {}; if (modifications.updatedAt === undefined) extra.updatedAt = Date.now(); if (modifications.dirty === undefined) extra.dirty = 1; localChange.notify?.(); return extra; }); } // Initialize default profile + settings on a fresh install db.on("populate", async () => { const now = Date.now(); const defaultProfileId = generateId(); await db.profiles.add({ id: defaultProfileId, name: "Moi", createdAt: now, updatedAt: now, }); await db.settings.add({ id: 1, theme: "system", language: "fr", activeProfileId: defaultProfileId, }); });