aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--persistence/db.ts115
-rw-r--r--persistence/index.ts19
-rw-r--r--persistence/schema.ts52
-rw-r--r--tests/unit/persistence/schema.test.ts71
4 files changed, 257 insertions, 0 deletions
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<World, string>
+ people!: Table<Person, string>
+ relationships!: Table<Relationship, string>
+ institutions!: Table<Institution, string>
+ places!: Table<Place, string>
+ worldEvents!: Table<WorldEvent, string>
+ eventLogs!: Table<EventLogEntry, string>
+ memoirs!: Table<Memoir, string>
+ routines!: Table<Routine, string>
+ flowHistory!: Table<FlowEntry, string>
+ scheduledEvents!: Table<ScheduledEvent, string>
+ settings!: Table<Setting, string>
+
+ 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<CachedContentChunk, string>
+ manifest!: Table<CachedManifest, string>
+
+ 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<CachedAudioTrack, string>
+ manifest!: Table<CachedManifest, string>
+
+ 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)
+ })
+})