import Dexie, { type EntityTable } from 'dexie'; import type { Campaign } from '@/lib/schemas/campaign'; import type { Character } from '@/lib/schemas/character'; import type { Encounter } from '@/lib/schemas/encounter'; import type { DiceRoll } from '@/lib/schemas/dice'; import type { Note, Npc, Quest, Calendar, BattleMap, Homebrew } from '@/lib/schemas/world'; import { characterDefaults } from '@/lib/schemas/character'; /** * Single Dexie instance for the whole app. Dexie wraps IndexedDB transactions * correctly (commits before resolving), which removes the silent-data-loss * class of bugs the old hand-rolled adapter shipped with. * * Schema versions are additive: bump `version(n)` and add a `.upgrade()` when * the shape changes — never edit a past version in place. */ export class TtrpgDatabase extends Dexie { campaigns!: EntityTable; characters!: EntityTable; encounters!: EntityTable; diceRolls!: EntityTable; notes!: EntityTable; npcs!: EntityTable; quests!: EntityTable; calendars!: EntityTable; maps!: EntityTable; homebrew!: EntityTable; constructor() { super('ttrpg-manager'); this.version(1).stores({ campaigns: 'id, updatedAt', characters: 'id, campaignId, kind', encounters: 'id, campaignId, status', diceRolls: 'id, campaignId, createdAt', }); // v2 — Phase 1 character depth: backfill new fields on existing characters. this.version(2).stores({}).upgrade(async (tx) => { await tx .table('characters') .toCollection() .modify((c: Record) => { for (const [key, value] of Object.entries(characterDefaults())) { if (c[key] === undefined) c[key] = value; } }); }); // v3 — Phase 3 combat depth: backfill the encounter event log. this.version(3).stores({}).upgrade(async (tx) => { await tx .table('encounters') .toCollection() .modify((e: Record) => { if (e.log === undefined) e.log = []; }); }); // v4 — Phase 5 worldbuilding: notes, NPCs, quests, calendar. this.version(4).stores({ notes: 'id, campaignId', npcs: 'id, campaignId', quests: 'id, campaignId, status', calendars: 'campaignId', }); // v5 — Phase 7 maps/VTT. this.version(5).stores({ maps: 'id, campaignId' }); // v6 — Phase 9 homebrew content. this.version(6).stores({ homebrew: 'id, campaignId, [campaignId+kind]' }); // v7 — Phase 13 assistant: encounters gain optional partyLevelsSnapshot + // outcome. Both optional, so existing rows stay valid; no backfill needed. this.version(7).stores({}); // v8 — Phase 17 VTT overhaul: maps gain drawings/gridUnit/gridType/fogVersion; // tokens gain size/kind/conditions/gmOnly. All defaulted, so backfill is // belt-and-suspenders (repo re-parses on save too). this.version(8).stores({}).upgrade(async (tx) => { await tx .table('maps') .toCollection() .modify((m: Record) => { if (m.drawings === undefined) m.drawings = []; if (m.gridUnit === undefined) m.gridUnit = { feet: 5 }; if (m.gridType === undefined) m.gridType = 'square'; if (m.fogVersion === undefined) m.fogVersion = 1; const tokens = Array.isArray(m.tokens) ? (m.tokens as Record[]) : []; for (const t of tokens) { if (t.size === undefined) t.size = 1; if (t.kind === undefined) t.kind = 'npc'; if (t.conditions === undefined) t.conditions = []; if (t.gmOnly === undefined) t.gmOnly = false; } }); }); // v9 — Phase 20: tokens gain optional combatantId (live HP from an encounter // combatant). Optional field — no backfill needed; existing rows parse fine. this.version(9).stores({}); // v10 — Phase 2: characters gain `portrait`, map tokens gain `image` // (both optional data URLs). No backfill needed. this.version(10).stores({}); // v11 — Phase 3: maps gain walls/doors/lights (UVTT import + dynamic vision). // Backfill empty arrays so code can read them on pre-existing maps. this.version(11).stores({}).upgrade(async (tx) => { await tx.table('maps').toCollection().modify((m: Record) => { if (m.walls === undefined) m.walls = []; if (m.doors === undefined) m.doors = []; if (m.lights === undefined) m.lights = []; }); }); } } export const db = new TtrpgDatabase();