diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-07 19:03:12 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-07 19:03:12 +0900 |
| commit | d91888da7199cdde7662910debfffaa758b8a128 (patch) | |
| tree | 6f31882306d6908286bf455fd1c5979ce6a43cf5 | |
| parent | 463952d9ab01b80f71d7e32f98da908723303079 (diff) | |
| download | kioku-d91888da7199cdde7662910debfffaa758b8a128.tar.gz kioku-d91888da7199cdde7662910debfffaa758b8a128.tar.zst kioku-d91888da7199cdde7662910debfffaa758b8a128.zip | |
feat(client): add Dexie.js setup for IndexedDB local storage
Set up Dexie database with LocalDeck, LocalCard, and LocalReviewLog
tables for offline support. Each entity includes _synced flag for
tracking synchronization status.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
| -rw-r--r-- | docs/dev/roadmap.md | 2 | ||||
| -rw-r--r-- | package.json | 2 | ||||
| -rw-r--r-- | pnpm-lock.yaml | 17 | ||||
| -rw-r--r-- | src/client/db/index.test.ts | 307 | ||||
| -rw-r--r-- | src/client/db/index.ts | 129 |
5 files changed, 456 insertions, 1 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md index 3cba614..ee7ec63 100644 --- a/docs/dev/roadmap.md +++ b/docs/dev/roadmap.md @@ -147,7 +147,7 @@ Smaller features first to enable early MVP validation. - [x] Add tests ### IndexedDB (Local Storage) -- [ ] Dexie.js setup +- [x] Dexie.js setup - [ ] Local schema (with _synced flag) - [ ] Local CRUD operations for Deck/Card/ReviewLog - [ ] Add tests diff --git a/package.json b/package.json index fcb1d31..727f63d 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "@hono/node-server": "^1.19.6", "@hono/zod-validator": "^0.7.5", "argon2": "^0.44.0", + "dexie": "^4.2.1", "drizzle-kit": "^0.31.8", "drizzle-orm": "^0.45.0", "hono": "^4.10.7", @@ -54,6 +55,7 @@ "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", "esbuild": "^0.27.1", + "fake-indexeddb": "^6.2.5", "jsdom": "^27.2.0", "typescript": "^5.9.3", "vite": "^7.2.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2962018..c98ada1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: argon2: specifier: ^0.44.0 version: 0.44.0 + dexie: + specifier: ^4.2.1 + version: 4.2.1 drizzle-kit: specifier: ^0.31.8 version: 0.31.8 @@ -78,6 +81,9 @@ importers: esbuild: specifier: ^0.27.1 version: 0.27.1 + fake-indexeddb: + specifier: ^6.2.5 + version: 6.2.5 jsdom: specifier: ^27.2.0 version: 27.2.0 @@ -1703,6 +1709,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + dexie@4.2.1: + resolution: {integrity: sha512-Ckej0NS6jxQ4Po3OrSQBFddayRhTCic2DoCAG5zacOfOVB9P2Q5Xc5uL/nVa7ZVs+HdMnvUPzLFCB/JwpB6Csg==} + dom-accessibility-api@0.5.16: resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} @@ -1895,6 +1904,10 @@ packages: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} + fake-indexeddb@6.2.5: + resolution: {integrity: sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==} + engines: {node: '>=18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -4489,6 +4502,8 @@ snapshots: dequal@2.0.3: {} + dexie@4.2.1: {} + dom-accessibility-api@0.5.16: {} drizzle-kit@0.31.8: @@ -4709,6 +4724,8 @@ snapshots: expect-type@1.2.2: {} + fake-indexeddb@6.2.5: {} + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} diff --git a/src/client/db/index.test.ts b/src/client/db/index.test.ts new file mode 100644 index 0000000..c1a62f5 --- /dev/null +++ b/src/client/db/index.test.ts @@ -0,0 +1,307 @@ +/** + * @vitest-environment jsdom + */ +import "fake-indexeddb/auto"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + CardState, + db, + type LocalCard, + type LocalDeck, + type LocalReviewLog, + Rating, +} from "./index"; + +describe("KiokuDatabase", () => { + beforeEach(async () => { + // Clear database before each test + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + }); + + afterEach(async () => { + // Clean up after tests + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + }); + + describe("database initialization", () => { + it("should have decks table", () => { + expect(db.decks).toBeDefined(); + }); + + it("should have cards table", () => { + expect(db.cards).toBeDefined(); + }); + + it("should have reviewLogs table", () => { + expect(db.reviewLogs).toBeDefined(); + }); + + it("should be named kioku", () => { + expect(db.name).toBe("kioku"); + }); + }); + + describe("decks table", () => { + const testDeck: LocalDeck = { + id: "deck-1", + userId: "user-1", + name: "Test Deck", + description: "A test deck", + newCardsPerDay: 20, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + _synced: false, + }; + + it("should add and retrieve a deck", async () => { + await db.decks.add(testDeck); + const retrieved = await db.decks.get("deck-1"); + expect(retrieved).toEqual(testDeck); + }); + + it("should find unsynced decks", async () => { + await db.decks.add(testDeck); + await db.decks.add({ + ...testDeck, + id: "deck-2", + name: "Synced Deck", + _synced: true, + }); + + const unsynced = await db.decks.filter((d) => !d._synced).toArray(); + expect(unsynced).toHaveLength(1); + expect(unsynced[0]?.id).toBe("deck-1"); + }); + + it("should find decks by userId", async () => { + await db.decks.add(testDeck); + await db.decks.add({ + ...testDeck, + id: "deck-2", + userId: "user-2", + }); + + const userDecks = await db.decks + .where("userId") + .equals("user-1") + .toArray(); + expect(userDecks).toHaveLength(1); + expect(userDecks[0]?.id).toBe("deck-1"); + }); + + it("should update a deck", async () => { + await db.decks.add(testDeck); + await db.decks.update("deck-1", { + name: "Updated Deck", + _synced: false, + }); + + const updated = await db.decks.get("deck-1"); + expect(updated?.name).toBe("Updated Deck"); + }); + + it("should delete a deck", async () => { + await db.decks.add(testDeck); + await db.decks.delete("deck-1"); + + const deleted = await db.decks.get("deck-1"); + expect(deleted).toBeUndefined(); + }); + }); + + describe("cards table", () => { + const testCard: LocalCard = { + id: "card-1", + deckId: "deck-1", + front: "Question", + back: "Answer", + state: CardState.New, + due: new Date("2024-01-01"), + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: new Date("2024-01-01"), + updatedAt: new Date("2024-01-01"), + deletedAt: null, + syncVersion: 0, + _synced: false, + }; + + it("should add and retrieve a card", async () => { + await db.cards.add(testCard); + const retrieved = await db.cards.get("card-1"); + expect(retrieved).toEqual(testCard); + }); + + it("should find cards by deckId", async () => { + await db.cards.add(testCard); + await db.cards.add({ + ...testCard, + id: "card-2", + deckId: "deck-2", + }); + + const deckCards = await db.cards + .where("deckId") + .equals("deck-1") + .toArray(); + expect(deckCards).toHaveLength(1); + expect(deckCards[0]?.id).toBe("card-1"); + }); + + it("should find due cards", async () => { + const now = new Date(); + const past = new Date(now.getTime() - 1000 * 60 * 60); + const future = new Date(now.getTime() + 1000 * 60 * 60); + + await db.cards.add({ ...testCard, id: "card-past", due: past }); + await db.cards.add({ ...testCard, id: "card-future", due: future }); + + const dueCards = await db.cards.where("due").belowOrEqual(now).toArray(); + expect(dueCards).toHaveLength(1); + expect(dueCards[0]?.id).toBe("card-past"); + }); + + it("should find cards by state", async () => { + await db.cards.add(testCard); + await db.cards.add({ + ...testCard, + id: "card-2", + state: CardState.Review, + }); + + const newCards = await db.cards + .where("state") + .equals(CardState.New) + .toArray(); + expect(newCards).toHaveLength(1); + expect(newCards[0]?.id).toBe("card-1"); + }); + + it("should find unsynced cards", async () => { + await db.cards.add(testCard); + await db.cards.add({ + ...testCard, + id: "card-2", + _synced: true, + }); + + const unsynced = await db.cards.filter((c) => !c._synced).toArray(); + expect(unsynced).toHaveLength(1); + expect(unsynced[0]?.id).toBe("card-1"); + }); + }); + + describe("reviewLogs table", () => { + const testReviewLog: LocalReviewLog = { + id: "review-1", + cardId: "card-1", + userId: "user-1", + rating: Rating.Good, + state: CardState.New, + scheduledDays: 1, + elapsedDays: 0, + reviewedAt: new Date("2024-01-01"), + durationMs: 5000, + syncVersion: 0, + _synced: false, + }; + + it("should add and retrieve a review log", async () => { + await db.reviewLogs.add(testReviewLog); + const retrieved = await db.reviewLogs.get("review-1"); + expect(retrieved).toEqual(testReviewLog); + }); + + it("should find review logs by cardId", async () => { + await db.reviewLogs.add(testReviewLog); + await db.reviewLogs.add({ + ...testReviewLog, + id: "review-2", + cardId: "card-2", + }); + + const cardReviews = await db.reviewLogs + .where("cardId") + .equals("card-1") + .toArray(); + expect(cardReviews).toHaveLength(1); + expect(cardReviews[0]?.id).toBe("review-1"); + }); + + it("should find review logs by userId", async () => { + await db.reviewLogs.add(testReviewLog); + await db.reviewLogs.add({ + ...testReviewLog, + id: "review-2", + userId: "user-2", + }); + + const userReviews = await db.reviewLogs + .where("userId") + .equals("user-1") + .toArray(); + expect(userReviews).toHaveLength(1); + expect(userReviews[0]?.id).toBe("review-1"); + }); + + it("should find unsynced review logs", async () => { + await db.reviewLogs.add(testReviewLog); + await db.reviewLogs.add({ + ...testReviewLog, + id: "review-2", + _synced: true, + }); + + const unsynced = await db.reviewLogs.filter((r) => !r._synced).toArray(); + expect(unsynced).toHaveLength(1); + expect(unsynced[0]?.id).toBe("review-1"); + }); + + it("should order review logs by reviewedAt", async () => { + const earlier = new Date("2024-01-01"); + const later = new Date("2024-01-02"); + + await db.reviewLogs.add({ + ...testReviewLog, + id: "review-later", + reviewedAt: later, + }); + await db.reviewLogs.add({ + ...testReviewLog, + id: "review-earlier", + reviewedAt: earlier, + }); + + const sorted = await db.reviewLogs.orderBy("reviewedAt").toArray(); + expect(sorted[0]?.id).toBe("review-earlier"); + expect(sorted[1]?.id).toBe("review-later"); + }); + }); + + describe("constants", () => { + it("should export CardState enum", () => { + expect(CardState.New).toBe(0); + expect(CardState.Learning).toBe(1); + expect(CardState.Review).toBe(2); + expect(CardState.Relearning).toBe(3); + }); + + it("should export Rating enum", () => { + expect(Rating.Again).toBe(1); + expect(Rating.Hard).toBe(2); + expect(Rating.Good).toBe(3); + expect(Rating.Easy).toBe(4); + }); + }); +}); diff --git a/src/client/db/index.ts b/src/client/db/index.ts new file mode 100644 index 0000000..4c381d2 --- /dev/null +++ b/src/client/db/index.ts @@ -0,0 +1,129 @@ +import Dexie, { type EntityTable } from "dexie"; + +/** + * Card states for FSRS algorithm + */ +export const CardState = { + New: 0, + Learning: 1, + Review: 2, + Relearning: 3, +} as const; + +export type CardStateType = (typeof CardState)[keyof typeof CardState]; + +/** + * Rating values for reviews + */ +export const Rating = { + Again: 1, + Hard: 2, + Good: 3, + Easy: 4, +} as const; + +export type RatingType = (typeof Rating)[keyof typeof Rating]; + +/** + * Local deck stored in IndexedDB + * Includes _synced flag for offline sync tracking + */ +export interface LocalDeck { + id: string; + userId: string; + name: string; + description: string | null; + newCardsPerDay: number; + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + syncVersion: number; + _synced: boolean; +} + +/** + * Local card stored in IndexedDB + * Includes _synced flag for offline sync tracking + */ +export interface LocalCard { + id: string; + deckId: string; + front: string; + back: string; + + // FSRS fields + state: CardStateType; + due: Date; + stability: number; + difficulty: number; + elapsedDays: number; + scheduledDays: number; + reps: number; + lapses: number; + lastReview: Date | null; + + createdAt: Date; + updatedAt: Date; + deletedAt: Date | null; + syncVersion: number; + _synced: boolean; +} + +/** + * Local review log stored in IndexedDB + * Includes _synced flag for offline sync tracking + * ReviewLog is append-only (no conflicts possible) + */ +export interface LocalReviewLog { + id: string; + cardId: string; + userId: string; + rating: RatingType; + state: CardStateType; + scheduledDays: number; + elapsedDays: number; + reviewedAt: Date; + durationMs: number | null; + syncVersion: number; + _synced: boolean; +} + +/** + * Kioku local database using Dexie (IndexedDB wrapper) + * + * This database stores decks, cards, and review logs locally for offline support. + * Each entity has a _synced flag to track whether it has been synchronized with the server. + */ +export class KiokuDatabase extends Dexie { + decks!: EntityTable<LocalDeck, "id">; + cards!: EntityTable<LocalCard, "id">; + reviewLogs!: EntityTable<LocalReviewLog, "id">; + + constructor() { + super("kioku"); + + this.version(1).stores({ + // Primary key is 'id', indexed fields follow + // userId: for filtering by user + // updatedAt: for sync ordering + // Note: _synced is not indexed (boolean fields can't be indexed in IndexedDB) + // Use .filter() to find unsynced items + decks: "id, userId, updatedAt", + + // deckId: for filtering cards by deck + // due: for finding due cards + // state: for filtering by card state + cards: "id, deckId, updatedAt, due, state", + + // cardId: for finding reviews for a card + // userId: for filtering by user + // reviewedAt: for ordering reviews + reviewLogs: "id, cardId, userId, reviewedAt", + }); + } +} + +/** + * Singleton instance of the Kioku database + */ +export const db = new KiokuDatabase(); |
