🎯

idb-state-persistence

🎯Skill

from matthewharwood/fantasy-phonics

VibeIndex|
What it does

idb-state-persistence skill from matthewharwood/fantasy-phonics

idb-state-persistence

Installation

Install skill:
npx skills add https://github.com/matthewharwood/fantasy-phonics --skill idb-state-persistence
1
AddedJan 29, 2026

Skill Details

SKILL.md

IndexedDB patterns for local-first state persistence using the idb library. Use when implementing features that require persistent state across navigation and sessions. Covers data modeling, defaults, CRUD operations, state managers, and reset patterns. Integrates with web-components for reactive UI updates.

Overview

# IDB State Persistence Architecture

Philosophy

Local Database First: IndexedDB (IDB) is the primary source of truth for all application state. Every user interaction that changes state MUST persist to IDB immediately to survive navigation, page refreshes, and session restarts.

This architecture treats IDB as a local database, not a cache. State lives in IDB first, components read from it, and all mutations write through to IDB synchronously (no debouncing or batching by default).

Why IDB over localStorage:

  • Structured data storage (objects, arrays, not just strings)
  • Indexed queries for efficient retrieval
  • Larger storage limits (hundreds of MB vs 5-10 MB)
  • Async API that doesn't block main thread

Relationship with Other Skills:

  • javascript: Provides error handling (Rule 1), timeout patterns (Rule 2), cleanup (Rule 4)
  • web-components: Components subscribe to IDB state changes via event listeners
  • Integration: State Managers act as the bridge between IDB and UI components

---

Core Patterns Overview

This project uses three IDB patterns working together:

  1. StateStore - Generic key-value store (simple CRUD, single object store)
  2. Specialized Stores - Domain-specific stores with indexes (StoryStore, PracticeProgressStore)
  3. State Managers - Wrapper classes that use StateStore + event system (GameStateManager, PracticeStateManager)

Architecture: Web Components β†’ State Manager (#cache, #listeners) β†’ StateStore/Specialized Store β†’ IndexedDB

---

Data Modeling Pattern

Principle: Define data structures using TypeScript-style JSDoc typedefs BEFORE writing any IDB code. Start with primitives, build objects, then compose arrays and nested structures.

Step 1: Define Types with JSDoc

```javascript

/**

* @typedef {Object} ActivityProgress

* @property {string} activityId - Unique activity identifier

* @property {number} tier - Tier number (1-5)

* @property {number} highScore - Best score achieved

* @property {number} totalAttempts - Total play count

* @property {number} currentStreak - Consecutive successful days

* @property {number} lastPlayed - Timestamp of last session

*/

/**

* @typedef {Object} PracticeSession

* @property {number} [id] - Auto-generated ID (optional, IDB creates it)

* @property {string} activityId - References ActivityProgress

* @property {number} timestamp - When session occurred

* @property {number} score - Session score (0-100)

* @property {string[]} phonemesTested - Array of phoneme IDs

*/

```

Conventions:

  • Primitives first: string, number, boolean
  • Timestamps: Always use Date.now() (milliseconds since epoch)
  • Optional fields: Use [id] syntax in JSDoc for auto-generated or conditional fields
  • References: Use matching ID fields (activityId links to ActivityProgress.activityId)
  • Arrays of objects: Define the object type first, then reference in array

Step 2: Define Default State

Create a module-level constant with complete initial state:

```javascript

const DEFAULT_PRACTICE_STATE = {

words: [],

phonemes: [],

currentTier: 1,

currentWordIndex: 0,

settings: {

autoAdvance: true,

soundEnabled: true,

showHints: true,

tierUnlocked: 5

},

status: 'loading' // loading, ready, error

};

```

Conventions:

  • Complete structure: Include ALL fields, even empty arrays/objects
  • Smart defaults: Choose sensible starting values (tier: 1, index: 0)
  • Status field: Always include a status field ('loading', 'ready', 'error')
  • Nested objects: Define full shape, don't use null for object properties
  • Use CONST: Defaults are immutable references, clone when using

---

Database Initialization Pattern

Pattern: Lazy Initialization with Module-Level Cache

```javascript

import { openDB } from 'idb';

const DB_NAME = 'fantasy-phonics-practice';

const DB_VERSION = 1;

const STORE_NAME = 'progress';

// Module-level promise cache (singleton pattern)

let dbPromise = null;

function getDB() {

if (!dbPromise) {

dbPromise = openDB(DB_NAME, DB_VERSION, {

upgrade(db) {

// Schema creation ONLY runs when DB doesn't exist or version changes

if (!db.objectStoreNames.contains(STORE_NAME)) {

const store = db.createObjectStore(STORE_NAME, {

keyPath: 'activityId' // Primary key from data

});

// Create indexes for common queries

store.createIndex('tier', 'tier', { unique: false });

store.createIndex('lastPlayed', 'lastPlayed', { unique: false });

}

}

});

}

return dbPromise;

}

```

Key Points:

  • One database per feature area: Separate DBs for game vs practice vs stories
  • Lazy init: Only create connection when first needed
  • Module-level cache: dbPromise is reused across all operations
  • Upgrade callback: Runs ONLY on version increment or first creation
  • Check existence: Always if (!db.objectStoreNames.contains(...))

Schema Design Principles

Object Store Naming:

  • Plural nouns: sessions, stories, achievements
  • Lowercase, no hyphens: phoneme_mastery (underscore OK for multi-word)

Key Paths:

  • keyPath: Use existing property for primary key (activityId, id)
  • autoIncrement: Use when IDs are auto-generated ({ keyPath: 'id', autoIncrement: true })
  • Out-of-line keys: Don't use if you can use keyPath (simpler API)

Indexes:

  • Create for frequently filtered fields (tier, date, timestamp)
  • Create for sorting operations (createdAt, lastPlayed)
  • Keep unique: false unless truly unique constraint needed

---

CRUD Operations

Pattern 1: Generic StateStore (Simple Key-Value)

Use this for single-document state (game state, settings, user profile).

```javascript

export const StateStore = {

async get(key) {

const db = await getDB();

return db.get(STORE_NAME, key);

},

async set(key, value) {

const db = await getDB();

return db.put(STORE_NAME, value, key);

},

async delete(key) {

const db = await getDB();

return db.delete(STORE_NAME, key);

},

async clear() {

const db = await getDB();

return db.clear(STORE_NAME);

},

// Helper: Get with fallback

async getOrDefault(key, fallback) {

const value = await this.get(key);

return value ?? fallback;

},

// Helper: Update function pattern

async update(key, updater) {

const current = await this.get(key);

const updated = updater(current);

await this.set(key, updated);

return updated;

}

};

```

When to use: Single state object per key (gameState, practiceMode, userSettings).

Pattern 2: Specialized Store with Indexes

Use this for collections with queries (stories, sessions, achievements).

```javascript

export const StoryStore = {

// CREATE

async save(storyData) {

const db = await getDB();

const story = {

id: generateId(), // Custom ID generation

wordSeq: storyData.wordSeq,

createdAt: Date.now(),

answers: storyData.answers

};

await db.put(STORE_NAME, story);

return story;

},

// READ - Single

async getById(id) {

const db = await getDB();

return db.get(STORE_NAME, id);

},

// READ - Collection (all)

async getAll() {

const db = await getDB();

return db.getAll(STORE_NAME);

},

// READ - Indexed query

async getByWord(wordSeq) {

const db = await getDB();

return db.getAllFromIndex(STORE_NAME, 'wordSeq', wordSeq);

},

// UPDATE

async update(id, updates) {

const db = await getDB();

const existing = await db.get(STORE_NAME, id);

if (!existing) throw new Error(Not found: ${id});

const updated = { ...existing, ...updates };

await db.put(STORE_NAME, updated);

return updated;

},

// DELETE

async delete(id) {

const db = await getDB();

await db.delete(STORE_NAME, id);

},

// CLEAR ALL

async clear() {

const db = await getDB();

await db.clear(STORE_NAME);

}

};

```

Index Queries:

  • db.getAllFromIndex(store, indexName, value) - Get all matching value
  • db.getAllFromIndex(store, indexName) - Get all, sorted by index
  • Use openCursor() for pagination or limit results

When to use: Multiple documents with filtering/sorting needs (story versions, practice sessions).

Pattern 3: State Manager with Cache

Use this for complex state with business logic (game progression, navigation).

```javascript

class GameStateManager {

#cache = null;

#listeners = new Map();

#ready;

constructor() {

this.#ready = this.#init();

}

async #init() {

const stored = await StateStore.get(KEY);

this.#cache = stored ? { ...DEFAULT, ...stored } : { ...DEFAULT };

if (!this.#cache.answers) this.#cache.answers = {}; // Ensure nested objects

return this;

}

async ready() { await this.#ready; return this; }

getState() { return { ...this.#cache }; }

async submitAnswer(text) {

this.#cache.answers[this.#cache.currentSeq] = { answer: text, timestamp: Date.now() };

await this.#persist();

this.#notify('answerSubmitted', { answer: text });

}

async #persist() { await StateStore.set(KEY, this.#cache); }

onChange(event, callback) {

if (!this.#listeners.has(event)) this.#listeners.set(event, new Set());

this.#listeners.get(event).add(callback);

return () => this.#listeners.get(event)?.delete(callback);

}

#notify(event, data) { this.#listeners.get(event)?.forEach(cb => cb(data)); }

}

export const gameState = new GameStateManager(); // Singleton

```

Key: Private cache, write-through persistence, pub/sub events, singleton export, async init pattern.

---

State Lifecycle Patterns

Initialization Flow

```javascript

async connectedCallback() {

await gameState.ready();

await gameState.loadAllData(); // Fetch JSON if needed

this.#unsubscribe = gameState.onChange('state', (data) => this.#render(data));

}

disconnectedCallback() {

if (this.#unsubscribe) this.#unsubscribe();

}

```

Pattern: Constructor starts #init() β†’ ready() awaits β†’ load data β†’ subscribe β†’ cleanup in disconnectedCallback.

Persistence Triggers

Write-Through Pattern (current implementation):

```javascript

async updateSetting(key, value) {

this.#cache.settings[key] = value;

await this.#persist(); // Immediate write

this.#notify('settingsChanged', this.#cache.settings);

}

```

When to persist:

  • βœ… After every state mutation (current pattern)
  • βœ… After data loading completes
  • βœ… After navigation/phase changes
  • ⚠️ NOT after every keystroke (implement debouncing at component level if needed)

Alternative: Debounced Persistence (not currently used, but valid):

```javascript

#persistTimeout = null;

async #debouncedPersist(delay = 500) {

clearTimeout(this.#persistTimeout);

this.#persistTimeout = setTimeout(async () => {

await StateStore.set(this.KEY, this.#cache);

}, delay);

}

```

---

Reset and Clear Patterns

Pattern 1: Full Reset (Delete & Reinitialize)

```javascript

async resetAll() {

// Clear cache to defaults

this.#cache = { ...DEFAULT_GAME_STATE };

this.#cache.answers = {};

this.#cache.completedWords = [];

// Persist clean state

await this.#persist();

// Reload external data

await this.loadAllData();

// Notify

this.#notify('reset', null);

this.#notify('state', this.getState());

}

```

Use when: User requests "Start Over" or "Reset Progress"

Pattern 2: Selective Clear (Delete by Key)

```javascript

async clearProgress(activityId) {

const db = await getDB();

await db.delete(STORES.PROGRESS, activityId);

}

async clearAllProgress() {

const db = await getDB();

await db.clear(STORES.PROGRESS);

}

```

Use when: Removing specific entries without affecting other data

Pattern 3: StateStore Clear (Entire Store)

```javascript

async clearAll() {

await StateStore.clear(); // Clears entire state object store

}

```

Use when: Nuking all app state (debug, testing, logout)

Default Restoration Logic

Merge pattern ensures missing fields get defaults:

```javascript

async #init() {

const stored = await StateStore.get(KEY);

// Merge stored with defaults (stored wins for existing keys)

this.#cache = stored ? { ...DEFAULT_STATE, ...stored } : { ...DEFAULT_STATE };

// Explicitly ensure nested objects exist (spread doesn't deep merge)

if (!this.#cache.answers) this.#cache.answers = {};

if (!this.#cache.settings) this.#cache.settings = { ...DEFAULT_STATE.settings };

}

```

Critical: Spread operator (...) only does shallow merge. Nested objects need explicit checks.

---

Integration with Web Components

State Managers provide the bridge between IDB and UI via event subscriptions.

Pattern: Subscribe in connectedCallback

```javascript

class MyComponent extends HTMLElement {

#unsubscribe = null;

async connectedCallback() {

await gameState.ready();

// Subscribe to state changes

this.#unsubscribe = gameState.onChange('state', (newState) => {

this.#handleStateChange(newState);

});

// Initial render

this.#handleStateChange(gameState.getState());

}

disconnectedCallback() {

// CRITICAL: Clean up subscription

if (this.#unsubscribe) this.#unsubscribe();

}

#handleStateChange(state) {

// Update attributes (triggers re-render via attributeChangedCallback)

this.setAttribute('current-phase', state.currentPhase);

this.setAttribute('progress', state.overallProgress);

}

}

```

Pattern: User Action β†’ State Update β†’ Persistence

```javascript

class AnswerInput extends HTMLElement {

handleEvent(e) {

if (e.type === 'submit') {

e.preventDefault();

const answer = this.getAttribute('value');

// Trigger state update (which persists internally)

gameState.submitAnswer(answer);

// State change notification will trigger re-render

}

}

}

```

Flow:

  1. User interacts with component
  2. Component calls State Manager method
  3. State Manager updates #cache, persists to IDB, notifies subscribers
  4. Component receives notification, updates attributes
  5. Attribute change triggers re-render (per web-components skill)

---

Advanced Patterns

Cursor Pagination: Use index.openCursor(null, 'prev') + while loop for large collections.

Aggregation: Load all with getAll(), reduce in memory (fine for <1000 records).

Versioning: Query existing count, increment version field: version = existing.length + 1.

---

Error Handling and Recovery

Pattern: Try-Catch with Context (from javascript skill)

```javascript

async loadAllData() {

try {

const data = await this.#fetchWithTimeout('/data/words.json');

this.#cache.words = data;

this.#cache.status = 'ready';

await this.#persist();

this.#notify('dataLoaded', true);

return true;

} catch (error) {

const errorMessage = error.name === 'AbortError'

? Data load timed out after ${TIMEOUT}ms

: error.message;

console.error('Failed to load data:', errorMessage, error);

this.#cache.status = 'error';

this.#notify('error', errorMessage);

return false;

}

}

```

Apply javascript Rule 1: Provide specific error context in catch blocks.

Timeout Pattern for Fetches (from javascript skill)

```javascript

async #fetchWithTimeout(url, timeout = 5_000) {

const controller = new AbortController();

const timeoutId = setTimeout(() => controller.abort(), timeout);

try {

const response = await fetch(url, { signal: controller.signal });

if (!response.ok) throw new Error(HTTP ${response.status});

return await response.json();

} finally {

clearTimeout(timeoutId);

}

}

```

Apply javascript Rule 2: Time-bound all async operations.

---

Anti-Patterns (What NOT to Do)

❌ Don't use localStorage - Max 5-10MB, blocks main thread, no indexing

❌ Don't store state in component fields - Lost on navigation/refresh

❌ Don't access IDB from components - Bypasses State Manager, breaks reactivity

❌ Don't forget await ready() - Cache is null before init completes

❌ Don't mutate cache without persisting - Changes lost on refresh

❌ Don't use nested transactions for simple ops - Single-store operations don't need transactions

---

Migration and Versioning

Schema Migration: Increment DB_VERSION, check oldVersion in upgrade callback:

```javascript

upgrade(db, oldVersion) {

if (oldVersion < 2) {

const store = transaction.objectStore(STORE_NAME);

if (!store.indexNames.contains('category')) {

store.createIndex('category', 'category', { unique: false });

}

}

}

```

Data Migration: Iterate records in upgrade, add missing fields with defaults. Test thoroughlyβ€”migrations run once per user.

---

Quick Reference: Adding a New Feature with IDB

Checklist

  1. Define data model (JSDoc typedefs for all entities)
  2. Define DEFAULT state (complete structure with sensible values)
  3. Choose pattern:

- Simple key-value? β†’ Use StateStore directly

- Collection with queries? β†’ Create specialized store with indexes

- Complex state with navigation? β†’ Create State Manager class

  1. Implement CRUD (get, set, update, delete, clear)
  2. Add persistence triggers (call persist after mutations)
  3. Implement reset (clear β†’ defaults β†’ persist)
  4. Export singleton (if using State Manager)
  5. Integrate with components:

- await manager.ready() in connectedCallback

- Subscribe via onChange()

- Clean up in disconnectedCallback

  1. Test lifecycle:

- Fresh state (no IDB data)

- Existing state (reload page)

- Reset flow

- Navigation persistence

---

Common Pitfalls

  1. Forgot await ready() β†’ Cache is null, methods fail
  2. Shallow merge loses nested objects β†’ Explicitly check nested fields after merge
  3. No cleanup in disconnectedCallback β†’ Memory leaks from subscriptions
  4. Direct cache mutation without persist β†’ Changes lost on refresh
  5. Using getAll() on large collections β†’ Use cursors for pagination
  6. Not handling IDB errors β†’ Quota exceeded, corrupted DB crashes app
  7. Storing functions/classes β†’ IDB only supports structured cloneable data

---

Related Skills

  • javascript: Error handling (Rule 1), timeouts (Rule 2), cleanup (Rule 4)
  • web-components: Component lifecycle, event-driven updates, attribute patterns
  • js-micro-utilities: Use debounce from utilities if implementing debounced persistence

---

Reference: Current Project Stores

StateStore (Generic KV)

  • File: js/services/StateStore.js
  • DB: fantasy-phonics-db
  • Store: state (single object store, no keyPath)
  • Usage: Game state, practice mode state, word progress

StoryStore (Specialized)

  • File: js/services/StoryStore.js
  • DB: fantasy-phonics-stories
  • Store: stories (keyPath: id)
  • Indexes: wordSeq, createdAt
  • Usage: Saved story versions

PracticeProgressStore (Specialized)

  • File: js/services/PracticeProgressStore.js
  • DB: fantasy-phonics-practice
  • Stores: progress, sessions, achievements, phoneme_mastery
  • Indexes: Various per store
  • Usage: Practice mode analytics and progress tracking

State Managers

  • GameStateManager: Wraps StateStore, manages 28-word progression
  • PracticeStateManager: Wraps StateStore, manages tier/word navigation
  • WordProgressManager: Wraps StateStore, tracks phase completion