From 176e6a175b48a110c97476707388af931f8f7a7a Mon Sep 17 00:00:00 2001 From: Bobby Date: Wed, 22 Apr 2026 07:19:23 +0530 Subject: Set up IndexedDB schema via Dexie --- persistence/db.ts | 115 ++++++++++++++++++++++++++++++++++ persistence/index.ts | 19 ++++++ persistence/schema.ts | 52 +++++++++++++++ tests/unit/persistence/schema.test.ts | 71 +++++++++++++++++++++ 4 files changed, 257 insertions(+) create mode 100644 persistence/db.ts create mode 100644 persistence/index.ts create mode 100644 persistence/schema.ts create mode 100644 tests/unit/persistence/schema.test.ts diff --git a/persistence/db.ts b/persistence/db.ts new file mode 100644 index 0000000..b80217c --- /dev/null +++ b/persistence/db.ts @@ -0,0 +1,115 @@ +import Dexie, { type Table } from 'dexie' + +import type { + EventLogEntry, + FlowEntry, + Institution, + Memoir, + Person, + Place, + Relationship, + Routine, + ScheduledEvent, + World, + WorldEvent +} from 'engine' +import type { JsonValue } from 'utils' + +import { + AUDIO_CACHE_DB_NAME, + AUDIO_CACHE_SCHEMA_V1, + CONTENT_CACHE_DB_NAME, + CONTENT_CACHE_SCHEMA_V1, + USER_DATA_DB_NAME, + USER_DATA_SCHEMA_V1 +} from './schema' + +/** + * A single key/value pair on the Settings table. Keys are short slugs + * (e.g., 'audio.masterMuted'); values serialise as JSON. + */ +export interface Setting { + readonly key: string + readonly value: JsonValue +} + +/** Cached content chunk — the in-memory registry is rebuilt from these on + * every session start. */ +export interface CachedContentChunk { + readonly chunkId: string + readonly version: string + readonly hash: string + readonly data: JsonValue + readonly fetchedAt: string +} + +/** Last-seen manifest, stored so the diff-download can decide what to + * re-fetch without a second round-trip. */ +export interface CachedManifest { + readonly id: 'latest' + readonly contentVersion: string + readonly appVersion: string + readonly generatedAt: string + readonly payload: JsonValue +} + +/** Cached audio track; mirrors CachedContentChunk but binary-friendly. */ +export interface CachedAudioTrack { + readonly trackId: string + readonly version: string + readonly hash: string + readonly data: Blob + readonly fetchedAt: string +} + +/** + * User save data — the player's world(s). Persists forever on device, + * untouched by content updates (technical/04-persistence.md §"Core + * decisions"). Changes to this schema require a migration. + */ +export class HollowdarkUserData extends Dexie { + worlds!: Table + people!: Table + relationships!: Table + institutions!: Table + places!: Table + worldEvents!: Table + eventLogs!: Table + memoirs!: Table + routines!: Table + flowHistory!: Table + scheduledEvents!: Table + settings!: Table + + constructor() { + super(USER_DATA_DB_NAME) + this.version(1).stores(USER_DATA_SCHEMA_V1) + } +} + +/** + * Content cache — the compiled JSON chunks the client fetched from the + * CDN. Independently versioned from user data; can be cleared without + * touching saves (ARCHITECTURE.md §3). + */ +export class HollowdarkContentCache extends Dexie { + content!: Table + manifest!: Table + + constructor() { + super(CONTENT_CACHE_DB_NAME) + this.version(1).stores(CONTENT_CACHE_SCHEMA_V1) + } +} + +/** Audio cache — decoded later in the AudioEngine; stored as Blobs so the + * browser can serve them through `URL.createObjectURL` without re-fetch. */ +export class HollowdarkAudioCache extends Dexie { + audio!: Table + manifest!: Table + + constructor() { + super(AUDIO_CACHE_DB_NAME) + this.version(1).stores(AUDIO_CACHE_SCHEMA_V1) + } +} diff --git a/persistence/index.ts b/persistence/index.ts new file mode 100644 index 0000000..f809246 --- /dev/null +++ b/persistence/index.ts @@ -0,0 +1,19 @@ +export { + HollowdarkAudioCache, + HollowdarkContentCache, + HollowdarkUserData, + type CachedAudioTrack, + type CachedContentChunk, + type CachedManifest, + type Setting +} from './db' + +export { + AUDIO_CACHE_DB_NAME, + AUDIO_CACHE_SCHEMA_V1, + CONTENT_CACHE_DB_NAME, + CONTENT_CACHE_SCHEMA_V1, + SCHEMA_VERSION, + USER_DATA_DB_NAME, + USER_DATA_SCHEMA_V1 +} from './schema' diff --git a/persistence/schema.ts b/persistence/schema.ts new file mode 100644 index 0000000..4d11edd --- /dev/null +++ b/persistence/schema.ts @@ -0,0 +1,52 @@ +/** + * IndexedDB schema strings for each of the three Dexie databases. + * + * The schema is versioned deliberately — any change here requires a + * migration in persistence/migrations.ts (added later). Snapshot tests + * lock these strings so accidental drift shows up as a failing test + * rather than a silent mismatch between code and on-device data. + * + * Index syntax recap: + * &field primary key (unique) + * ++id auto-incrementing primary key + * field plain index on a scalar + * [a+b] compound index + * *field multi-entry index on an array field + * + * See ARCHITECTURE.md §24 for the user-data schema and §7 for the content + * cache; technical/04-persistence.md for the two-database separation + * (user data persists forever, content is replaceable per update). + */ + +export const USER_DATA_SCHEMA_V1 = Object.freeze({ + worlds: '&id, seed, currentPlayerCharacterId', + people: + '&id, tier, isPlayerCharacter, lastSimulatedAt, currentPlaceId, [currentPlaceId+tier], *relationshipIds', + relationships: + '&id, personAId, personBId, type, currentState, lastInteractionAt, [personAId+personBId], [type+currentState]', + institutions: '&id, placeId, type', + places: '&id, parentPlaceId, type', + worldEvents: '&id, category, startedAt, endedAt', + eventLogs: '&id, personId, time, [personId+time]', + memoirs: '&id, personId, generatedAt', + routines: '&id, personId', + flowHistory: '&id, personId, time, [personId+time]', + scheduledEvents: '&id, targetPersonId, priority', + settings: '&key' +}) + +export const CONTENT_CACHE_SCHEMA_V1 = Object.freeze({ + content: '&chunkId, version, fetchedAt', + manifest: '&id' +}) + +export const AUDIO_CACHE_SCHEMA_V1 = Object.freeze({ + audio: '&trackId, version, fetchedAt', + manifest: '&id' +}) + +export const SCHEMA_VERSION = 1 as const + +export const USER_DATA_DB_NAME = 'HollowdarkUserData' as const +export const CONTENT_CACHE_DB_NAME = 'HollowdarkContentCache' as const +export const AUDIO_CACHE_DB_NAME = 'HollowdarkAudioCache' as const diff --git a/tests/unit/persistence/schema.test.ts b/tests/unit/persistence/schema.test.ts new file mode 100644 index 0000000..1c24388 --- /dev/null +++ b/tests/unit/persistence/schema.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from 'vitest' +import { + AUDIO_CACHE_DB_NAME, + AUDIO_CACHE_SCHEMA_V1, + CONTENT_CACHE_DB_NAME, + CONTENT_CACHE_SCHEMA_V1, + SCHEMA_VERSION, + USER_DATA_DB_NAME, + USER_DATA_SCHEMA_V1 +} from 'persistence' + +/** + * The Dexie schema is a versioned contract with on-device storage. Any + * change to these strings requires a migration in persistence/migrations + * (added later). These snapshots lock the v1 shape so drift fails loudly + * rather than silently breaking existing saves. + */ +describe('persistence schema v1 — locked shapes', () => { + test('schema version is 1', () => { + expect(SCHEMA_VERSION).toBe(1) + }) + + test('database names', () => { + expect(USER_DATA_DB_NAME).toBe('HollowdarkUserData') + expect(CONTENT_CACHE_DB_NAME).toBe('HollowdarkContentCache') + expect(AUDIO_CACHE_DB_NAME).toBe('HollowdarkAudioCache') + }) + + test('user-data schema v1 snapshot', () => { + expect(USER_DATA_SCHEMA_V1).toMatchInlineSnapshot(` + { + "eventLogs": "&id, personId, time, [personId+time]", + "flowHistory": "&id, personId, time, [personId+time]", + "institutions": "&id, placeId, type", + "memoirs": "&id, personId, generatedAt", + "people": "&id, tier, isPlayerCharacter, lastSimulatedAt, currentPlaceId, [currentPlaceId+tier], *relationshipIds", + "places": "&id, parentPlaceId, type", + "relationships": "&id, personAId, personBId, type, currentState, lastInteractionAt, [personAId+personBId], [type+currentState]", + "routines": "&id, personId", + "scheduledEvents": "&id, targetPersonId, priority", + "settings": "&key", + "worldEvents": "&id, category, startedAt, endedAt", + "worlds": "&id, seed, currentPlayerCharacterId", + } + `) + }) + + test('content-cache schema v1 snapshot', () => { + expect(CONTENT_CACHE_SCHEMA_V1).toMatchInlineSnapshot(` + { + "content": "&chunkId, version, fetchedAt", + "manifest": "&id", + } + `) + }) + + test('audio-cache schema v1 snapshot', () => { + expect(AUDIO_CACHE_SCHEMA_V1).toMatchInlineSnapshot(` + { + "audio": "&trackId, version, fetchedAt", + "manifest": "&id", + } + `) + }) + + test('schema objects are frozen so a typo at call-time throws', () => { + expect(Object.isFrozen(USER_DATA_SCHEMA_V1)).toBe(true) + expect(Object.isFrozen(CONTENT_CACHE_SCHEMA_V1)).toBe(true) + expect(Object.isFrozen(AUDIO_CACHE_SCHEMA_V1)).toBe(true) + }) +}) -- cgit v1.2.3