202 lines
6.2 KiB
TypeScript
202 lines
6.2 KiB
TypeScript
import Dexie, { Table } from "dexie";
|
|
import {
|
|
GameSession,
|
|
AppSettings,
|
|
SavedPlayer,
|
|
Location,
|
|
Profile,
|
|
SyncState,
|
|
} from "../types";
|
|
import { ImportedGame } from "../types/gameImport";
|
|
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<GameSession, string>;
|
|
settings!: Table<AppSettings, number>;
|
|
players!: Table<SavedPlayer, string>;
|
|
locations!: Table<Location, string>;
|
|
profiles!: Table<Profile, string>;
|
|
syncState!: Table<SyncState, string>;
|
|
importedGames!: Table<ImportedGame, [string, string]>;
|
|
|
|
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;
|
|
});
|
|
}
|
|
});
|
|
|
|
// Version 6: imported game definitions (per profile)
|
|
this.version(6).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",
|
|
importedGames: "[profileId+id], profileId, id, name",
|
|
});
|
|
}
|
|
}
|
|
|
|
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<string, unknown> = {};
|
|
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,
|
|
});
|
|
});
|