aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/client/sync
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/sync')
-rw-r--r--src/client/sync/index.ts7
-rw-r--r--src/client/sync/queue.ts8
-rw-r--r--src/client/sync/scheduler.test.ts252
-rw-r--r--src/client/sync/scheduler.ts155
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);
+ }
+}