diff options
Diffstat (limited to 'src/client/sync')
| -rw-r--r-- | src/client/sync/index.ts | 7 | ||||
| -rw-r--r-- | src/client/sync/queue.ts | 8 | ||||
| -rw-r--r-- | src/client/sync/scheduler.test.ts | 252 | ||||
| -rw-r--r-- | src/client/sync/scheduler.ts | 155 |
4 files changed, 422 insertions, 0 deletions
diff --git a/src/client/sync/index.ts b/src/client/sync/index.ts index 9e86f2a..d565569 100644 --- a/src/client/sync/index.ts +++ b/src/client/sync/index.ts @@ -43,3 +43,10 @@ export { type SyncStatusType, syncQueue, } from "./queue"; +export { + cacheStudyCards, + type ServerStudyCard, + type SubmitReviewResult, + submitReviewLocal, + undoReviewLocal, +} from "./scheduler"; diff --git a/src/client/sync/queue.ts b/src/client/sync/queue.ts index 984edc3..b097159 100644 --- a/src/client/sync/queue.ts +++ b/src/client/sync/queue.ts @@ -246,6 +246,14 @@ export class SyncQueue { } /** + * Notify listeners that pending changes may have been added externally + * (e.g., after writing to IndexedDB outside of the sync flow). + */ + async notifyChanged(): Promise<void> { + await this.notifyListeners(); + } + + /** * Mark items as synced after successful push */ async markSynced(results: { diff --git a/src/client/sync/scheduler.test.ts b/src/client/sync/scheduler.test.ts new file mode 100644 index 0000000..adee34e --- /dev/null +++ b/src/client/sync/scheduler.test.ts @@ -0,0 +1,252 @@ +/** + * @vitest-environment jsdom + */ +import "fake-indexeddb/auto"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { CardState, db, Rating } from "../db/index"; +import { + localCardRepository, + localDeckRepository, + localReviewLogRepository, +} from "../db/repositories"; +import { syncQueue } from "./queue"; +import { + cacheStudyCards, + type ServerStudyCard, + submitReviewLocal, + undoReviewLocal, +} from "./scheduler"; + +async function clearDb() { + await db.decks.clear(); + await db.cards.clear(); + await db.reviewLogs.clear(); + await db.noteTypes.clear(); + await db.noteFieldTypes.clear(); + await db.notes.clear(); + await db.noteFieldValues.clear(); +} + +async function seedDeck() { + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + defaultNoteTypeId: null, + }); + await localDeckRepository.markSynced(deck.id, 1); + return deck; +} + +async function seedSyncedCard(deckId: string) { + const card = await localCardRepository.create({ + deckId, + noteId: "note-1", + isReversed: false, + front: "front", + back: "back", + }); + await localCardRepository.markSynced(card.id, 1); + return card; +} + +describe("submitReviewLocal", () => { + beforeEach(async () => { + await clearDb(); + localStorage.clear(); + }); + + afterEach(async () => { + await clearDb(); + localStorage.clear(); + }); + + it("updates card scheduling and creates a review log in IndexedDB", async () => { + const deck = await seedDeck(); + const card = await seedSyncedCard(deck.id); + + const result = await submitReviewLocal({ + cardId: card.id, + rating: Rating.Good, + durationMs: 5000, + }); + + expect(result.card.reps).toBe(1); + expect(result.card.lastReview).toBeInstanceOf(Date); + expect(result.card._synced).toBe(false); + expect(result.reviewLogId).toBeDefined(); + + const logs = await localReviewLogRepository.findByCardId(card.id); + expect(logs).toHaveLength(1); + expect(logs[0]?.rating).toBe(Rating.Good); + expect(logs[0]?.userId).toBe(deck.userId); + expect(logs[0]?.durationMs).toBe(5000); + expect(logs[0]?._synced).toBe(false); + }); + + it("returns the previous card snapshot for undo", async () => { + const deck = await seedDeck(); + const card = await seedSyncedCard(deck.id); + + const result = await submitReviewLocal({ + cardId: card.id, + rating: Rating.Again, + durationMs: 3000, + }); + + expect(result.prevCard.reps).toBe(0); + expect(result.prevCard.state).toBe(CardState.New); + }); + + it("queues 5 offline reviews and exposes them as pending changes", async () => { + const deck = await seedDeck(); + const cards = await Promise.all( + Array.from({ length: 5 }, () => seedSyncedCard(deck.id)), + ); + + for (const card of cards) { + await submitReviewLocal({ + cardId: card.id, + rating: Rating.Good, + durationMs: 1000, + }); + } + + const pending = await syncQueue.getPendingChanges(); + expect(pending.cards.filter((c) => !c._synced)).toHaveLength(5); + expect(pending.reviewLogs).toHaveLength(5); + }); + + it("notifies sync queue listeners after each review", async () => { + const deck = await seedDeck(); + const card = await seedSyncedCard(deck.id); + + const counts: number[] = []; + const unsub = syncQueue.subscribe((state) => { + counts.push(state.pendingCount); + }); + + await submitReviewLocal({ + cardId: card.id, + rating: Rating.Good, + durationMs: 1000, + }); + + unsub(); + // Card was synced=true before; now both card and reviewLog are unsynced. + expect(counts.at(-1)).toBe(2); + }); + + it("throws when the card is missing from local DB", async () => { + await expect( + submitReviewLocal({ + cardId: "missing-card", + rating: Rating.Good, + durationMs: 1000, + }), + ).rejects.toThrow(/Card not found/); + }); +}); + +describe("undoReviewLocal", () => { + beforeEach(async () => { + await clearDb(); + localStorage.clear(); + }); + + afterEach(async () => { + await clearDb(); + localStorage.clear(); + }); + + it("restores the card and removes the review log", async () => { + const deck = await seedDeck(); + const card = await seedSyncedCard(deck.id); + + const result = await submitReviewLocal({ + cardId: card.id, + rating: Rating.Good, + durationMs: 1000, + }); + + await undoReviewLocal({ + prevCard: result.prevCard, + reviewLogId: result.reviewLogId, + }); + + const restored = await localCardRepository.findById(card.id); + expect(restored?.reps).toBe(0); + expect(restored?.state).toBe(CardState.New); + + const logs = await localReviewLogRepository.findByCardId(card.id); + expect(logs).toHaveLength(0); + }); +}); + +describe("cacheStudyCards", () => { + beforeEach(async () => { + await clearDb(); + localStorage.clear(); + }); + + afterEach(async () => { + await clearDb(); + localStorage.clear(); + }); + + function makeServerCard(id: string): ServerStudyCard { + return { + id, + deckId: "deck-1", + noteId: `note-${id}`, + isReversed: false, + front: "front", + back: "back", + state: 0, + due: "2026-05-02T00:00:00.000Z", + stability: 0, + difficulty: 0, + elapsedDays: 0, + scheduledDays: 0, + reps: 0, + lapses: 0, + lastReview: null, + createdAt: "2026-05-01T00:00:00.000Z", + updatedAt: "2026-05-01T00:00:00.000Z", + deletedAt: null, + syncVersion: 1, + }; + } + + it("upserts new cards into IndexedDB as synced", async () => { + await cacheStudyCards([makeServerCard("card-1"), makeServerCard("card-2")]); + + const card1 = await localCardRepository.findById("card-1"); + expect(card1?._synced).toBe(true); + expect(card1?.due).toBeInstanceOf(Date); + expect(card1?.syncVersion).toBe(1); + + const card2 = await localCardRepository.findById("card-2"); + expect(card2).toBeDefined(); + }); + + it("does not clobber unsynced local edits", async () => { + const deck = await seedDeck(); + const card = await seedSyncedCard(deck.id); + await submitReviewLocal({ + cardId: card.id, + rating: Rating.Good, + durationMs: 1000, + }); + + const before = await localCardRepository.findById(card.id); + expect(before?._synced).toBe(false); + + // Simulate the server returning a stale view of this card. + await cacheStudyCards([{ ...makeServerCard(card.id), reps: 0, state: 0 }]); + + const after = await localCardRepository.findById(card.id); + expect(after?._synced).toBe(false); + expect(after?.reps).toBe(1); + }); +}); diff --git a/src/client/sync/scheduler.ts b/src/client/sync/scheduler.ts new file mode 100644 index 0000000..72a6e25 --- /dev/null +++ b/src/client/sync/scheduler.ts @@ -0,0 +1,155 @@ +import { computeNextSchedule } from "../../shared/fsrs"; +import { db, type LocalCard, type RatingType } from "../db"; +import { + localCardRepository, + localDeckRepository, + localReviewLogRepository, +} from "../db/repositories"; +import { syncQueue } from "./queue"; + +export interface SubmitReviewResult { + /** The card after the review is applied. */ + card: LocalCard; + /** Snapshot of the card before the review — used by undo. */ + prevCard: LocalCard; + /** The newly created review log id — used by undo. */ + reviewLogId: string; +} + +/** + * Submit a review locally: update card scheduling and create a review log + * in IndexedDB. The sync engine will pick up the changes via _synced=false. + */ +export async function submitReviewLocal(params: { + cardId: string; + rating: RatingType; + durationMs: number; + now?: Date; +}): Promise<SubmitReviewResult> { + const { cardId, rating, durationMs } = params; + const now = params.now ?? new Date(); + + const card = await localCardRepository.findById(cardId); + if (!card) { + throw new Error(`Card not found in local database: ${cardId}`); + } + + const deck = await localDeckRepository.findById(card.deckId); + if (!deck) { + throw new Error(`Deck not found in local database: ${card.deckId}`); + } + + const prevCard = card; + const previousState = card.state; + + const next = computeNextSchedule(card, rating, now); + + const updatedCard = await localCardRepository.updateScheduling(cardId, { + state: next.state as LocalCard["state"], + due: next.due, + stability: next.stability, + difficulty: next.difficulty, + elapsedDays: next.elapsedDays, + scheduledDays: next.scheduledDays, + reps: next.reps, + lapses: next.lapses, + lastReview: next.lastReview, + }); + if (!updatedCard) { + throw new Error(`Failed to update card: ${cardId}`); + } + + const reviewLog = await localReviewLogRepository.create({ + cardId, + userId: deck.userId, + rating, + state: previousState, + scheduledDays: next.scheduledDays, + elapsedDays: next.reviewElapsedDays, + reviewedAt: now, + durationMs, + }); + + await syncQueue.notifyChanged(); + + return { card: updatedCard, prevCard, reviewLogId: reviewLog.id }; +} + +/** + * Undo a recent review: restore the previous card state and remove the + * just-created review log. Best-effort — if a sync has already pushed the + * review, the server still has it. + */ +export async function undoReviewLocal(params: { + prevCard: LocalCard; + reviewLogId: string; +}): Promise<void> { + await db.cards.put({ ...params.prevCard }); + await localReviewLogRepository.delete(params.reviewLogId); + await syncQueue.notifyChanged(); +} + +/** + * Server-shaped study card. Includes all FSRS fields needed to reconstruct + * a LocalCard so we can submit reviews offline. + */ +export interface ServerStudyCard { + id: string; + deckId: string; + noteId: string; + isReversed: boolean; + front: string; + back: string; + state: number; + due: string; + stability: number; + difficulty: number; + elapsedDays: number; + scheduledDays: number; + reps: number; + lapses: number; + lastReview: string | null; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + syncVersion: number; +} + +/** + * Cache study cards into IndexedDB so the scheduler can submit reviews + * even when the network drops mid-session. Only cards are cached here — + * note types / fields / values come through the regular sync pull. + */ +export async function cacheStudyCards(cards: ServerStudyCard[]): Promise<void> { + for (const c of cards) { + const local: LocalCard = { + id: c.id, + deckId: c.deckId, + noteId: c.noteId, + isReversed: c.isReversed, + front: c.front, + back: c.back, + state: c.state as LocalCard["state"], + due: new Date(c.due), + stability: c.stability, + difficulty: c.difficulty, + elapsedDays: c.elapsedDays, + scheduledDays: c.scheduledDays, + reps: c.reps, + lapses: c.lapses, + lastReview: c.lastReview ? new Date(c.lastReview) : null, + createdAt: new Date(c.createdAt), + updatedAt: new Date(c.updatedAt), + deletedAt: c.deletedAt ? new Date(c.deletedAt) : null, + syncVersion: c.syncVersion, + _synced: true, + }; + + // Don't clobber pending local edits (e.g., a review that hasn't + // been pushed yet). If the local copy has unsynced changes, skip. + const existing = await localCardRepository.findById(c.id); + if (existing && !existing._synced) continue; + + await localCardRepository.upsertFromServer(local); + } +} |
