aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/server/repositories
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/repositories')
-rw-r--r--src/server/repositories/card.test.ts138
-rw-r--r--src/server/repositories/card.ts20
-rw-r--r--src/server/repositories/deck.test.ts212
-rw-r--r--src/server/repositories/deck.ts3
-rw-r--r--src/server/repositories/note.test.ts198
-rw-r--r--src/server/repositories/note.ts134
-rw-r--r--src/server/repositories/noteType.test.ts81
-rw-r--r--src/server/repositories/noteType.ts3
-rw-r--r--src/server/repositories/sync.test.ts397
-rw-r--r--src/server/repositories/sync.ts12
-rw-r--r--src/server/repositories/types.ts20
11 files changed, 1207 insertions, 11 deletions
diff --git a/src/server/repositories/card.test.ts b/src/server/repositories/card.test.ts
index 4263dad..b492fd7 100644
--- a/src/server/repositories/card.test.ts
+++ b/src/server/repositories/card.test.ts
@@ -111,6 +111,7 @@ function createMockCardRepo(): CardRepository {
softDelete: vi.fn(),
softDeleteByNoteId: vi.fn(),
findDueCards: vi.fn(),
+ countDueCards: vi.fn(),
findDueCardsWithNoteData: vi.fn(),
findDueCardsForStudy: vi.fn(),
updateFSRSFields: vi.fn(),
@@ -504,3 +505,140 @@ describe("Card deletion behavior", () => {
});
});
});
+
+describe("findByDeckId ordering", () => {
+ it("returns cards ordered by createdAt", async () => {
+ const repo = createMockCardRepo();
+
+ const oldCard = createMockCard({
+ id: "card-old",
+ createdAt: new Date("2024-01-01"),
+ });
+ const newCard = createMockCard({
+ id: "card-new",
+ createdAt: new Date("2024-06-01"),
+ });
+
+ vi.mocked(repo.findByDeckId).mockResolvedValue([oldCard, newCard]);
+
+ const results = await repo.findByDeckId("deck-123");
+
+ expect(results).toHaveLength(2);
+ expect(results[0]?.id).toBe("card-old");
+ expect(results[1]?.id).toBe("card-new");
+ expect(results[0]?.createdAt.getTime()).toBeLessThan(
+ results[1]?.createdAt.getTime() ?? 0,
+ );
+ });
+
+ it("returns empty array when deck has no cards", async () => {
+ const repo = createMockCardRepo();
+
+ vi.mocked(repo.findByDeckId).mockResolvedValue([]);
+
+ const results = await repo.findByDeckId("deck-with-no-cards");
+ expect(results).toHaveLength(0);
+ });
+
+ it("maintains consistent ordering across multiple calls", async () => {
+ const repo = createMockCardRepo();
+
+ const card1 = createMockCard({
+ id: "card-1",
+ createdAt: new Date("2024-01-01"),
+ });
+ const card2 = createMockCard({
+ id: "card-2",
+ createdAt: new Date("2024-02-01"),
+ });
+ const card3 = createMockCard({
+ id: "card-3",
+ createdAt: new Date("2024-03-01"),
+ });
+
+ vi.mocked(repo.findByDeckId).mockResolvedValue([card1, card2, card3]);
+
+ const results1 = await repo.findByDeckId("deck-123");
+ const results2 = await repo.findByDeckId("deck-123");
+
+ expect(results1.map((c) => c.id)).toEqual(results2.map((c) => c.id));
+ expect(results1.map((c) => c.id)).toEqual(["card-1", "card-2", "card-3"]);
+ });
+});
+
+describe("findByNoteId ordering", () => {
+ it("returns cards ordered by isReversed (normal card first)", async () => {
+ const repo = createMockCardRepo();
+
+ const normalCard = createMockCard({
+ id: "card-normal",
+ noteId: "note-123",
+ isReversed: false,
+ });
+ const reversedCard = createMockCard({
+ id: "card-reversed",
+ noteId: "note-123",
+ isReversed: true,
+ });
+
+ // Mock returns cards in isReversed order (false first, true second)
+ vi.mocked(repo.findByNoteId).mockResolvedValue([normalCard, reversedCard]);
+
+ const results = await repo.findByNoteId("note-123");
+
+ expect(results).toHaveLength(2);
+ expect(results[0]?.id).toBe("card-normal");
+ expect(results[0]?.isReversed).toBe(false);
+ expect(results[1]?.id).toBe("card-reversed");
+ expect(results[1]?.isReversed).toBe(true);
+ });
+
+ it("returns single card for non-reversible note type", async () => {
+ const repo = createMockCardRepo();
+
+ const normalCard = createMockCard({
+ id: "card-only",
+ noteId: "note-123",
+ isReversed: false,
+ });
+
+ vi.mocked(repo.findByNoteId).mockResolvedValue([normalCard]);
+
+ const results = await repo.findByNoteId("note-123");
+
+ expect(results).toHaveLength(1);
+ expect(results[0]?.isReversed).toBe(false);
+ });
+
+ it("returns empty array when note has no cards", async () => {
+ const repo = createMockCardRepo();
+
+ vi.mocked(repo.findByNoteId).mockResolvedValue([]);
+
+ const results = await repo.findByNoteId("note-without-cards");
+ expect(results).toHaveLength(0);
+ });
+
+ it("maintains consistent ordering across multiple calls", async () => {
+ const repo = createMockCardRepo();
+
+ const normalCard = createMockCard({
+ id: "card-normal",
+ noteId: "note-123",
+ isReversed: false,
+ });
+ const reversedCard = createMockCard({
+ id: "card-reversed",
+ noteId: "note-123",
+ isReversed: true,
+ });
+
+ vi.mocked(repo.findByNoteId).mockResolvedValue([normalCard, reversedCard]);
+
+ const results1 = await repo.findByNoteId("note-123");
+ const results2 = await repo.findByNoteId("note-123");
+
+ expect(results1.map((c) => c.id)).toEqual(results2.map((c) => c.id));
+ expect(results1.map((c) => c.isReversed)).toEqual([false, true]);
+ });
+});
diff --git a/src/server/repositories/card.ts b/src/server/repositories/card.ts
index 761b317..ac03bc6 100644
--- a/src/server/repositories/card.ts
+++ b/src/server/repositories/card.ts
@@ -20,7 +20,8 @@ export const cardRepository: CardRepository = {
const result = await db
.select()
.from(cards)
- .where(and(eq(cards.deckId, deckId), isNull(cards.deletedAt)));
+ .where(and(eq(cards.deckId, deckId), isNull(cards.deletedAt)))
+ .orderBy(cards.createdAt);
return result;
},
@@ -73,7 +74,8 @@ export const cardRepository: CardRepository = {
const result = await db
.select()
.from(cards)
- .where(and(eq(cards.noteId, noteId), isNull(cards.deletedAt)));
+ .where(and(eq(cards.noteId, noteId), isNull(cards.deletedAt)))
+ .orderBy(cards.isReversed);
return result;
},
@@ -202,6 +204,20 @@ export const cardRepository: CardRepository = {
return result;
},
+ async countDueCards(deckId: string, now: Date): Promise<number> {
+ const result = await db
+ .select({ count: sql<number>`count(*)::int` })
+ .from(cards)
+ .where(
+ and(
+ eq(cards.deckId, deckId),
+ isNull(cards.deletedAt),
+ lte(cards.due, now),
+ ),
+ );
+ return result[0]?.count ?? 0;
+ },
+
async findDueCardsWithNoteData(
deckId: string,
now: Date,
diff --git a/src/server/repositories/deck.test.ts b/src/server/repositories/deck.test.ts
new file mode 100644
index 0000000..945f844
--- /dev/null
+++ b/src/server/repositories/deck.test.ts
@@ -0,0 +1,212 @@
+import { describe, expect, it, vi } from "vitest";
+import type { Deck, DeckRepository } from "./types.js";
+
+function createMockDeck(overrides: Partial<Deck> = {}): Deck {
+ return {
+ id: "deck-uuid-123",
+ userId: "user-uuid-123",
+ name: "Test Deck",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ deletedAt: null,
+ syncVersion: 0,
+ ...overrides,
+ };
+}
+
+function createMockDeckRepo(): DeckRepository {
+ return {
+ findByUserId: vi.fn(),
+ findById: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ softDelete: vi.fn(),
+ };
+}
+
+describe("DeckRepository mock factory", () => {
+ describe("createMockDeck", () => {
+ it("creates a valid Deck with defaults", () => {
+ const deck = createMockDeck();
+
+ expect(deck.id).toBe("deck-uuid-123");
+ expect(deck.userId).toBe("user-uuid-123");
+ expect(deck.name).toBe("Test Deck");
+ expect(deck.description).toBeNull();
+ expect(deck.newCardsPerDay).toBe(20);
+ expect(deck.deletedAt).toBeNull();
+ expect(deck.syncVersion).toBe(0);
+ });
+
+ it("allows overriding properties", () => {
+ const deck = createMockDeck({
+ id: "custom-id",
+ name: "Custom Deck",
+ description: "A description",
+ newCardsPerDay: 50,
+ });
+
+ expect(deck.id).toBe("custom-id");
+ expect(deck.name).toBe("Custom Deck");
+ expect(deck.description).toBe("A description");
+ expect(deck.newCardsPerDay).toBe(50);
+ });
+ });
+
+ describe("createMockDeckRepo", () => {
+ it("creates a repository with all required methods", () => {
+ const repo = createMockDeckRepo();
+
+ expect(repo.findByUserId).toBeDefined();
+ expect(repo.findById).toBeDefined();
+ expect(repo.create).toBeDefined();
+ expect(repo.update).toBeDefined();
+ expect(repo.softDelete).toBeDefined();
+ });
+
+ it("methods are mockable for findByUserId", async () => {
+ const repo = createMockDeckRepo();
+ const mockDecks = [
+ createMockDeck({ id: "deck-1" }),
+ createMockDeck({ id: "deck-2" }),
+ ];
+
+ vi.mocked(repo.findByUserId).mockResolvedValue(mockDecks);
+
+ const results = await repo.findByUserId("user-123");
+ expect(results).toHaveLength(2);
+ expect(repo.findByUserId).toHaveBeenCalledWith("user-123");
+ });
+
+ it("methods are mockable for findById", async () => {
+ const repo = createMockDeckRepo();
+ const mockDeck = createMockDeck();
+
+ vi.mocked(repo.findById).mockResolvedValue(mockDeck);
+
+ const found = await repo.findById("deck-id", "user-id");
+ expect(found).toEqual(mockDeck);
+ expect(repo.findById).toHaveBeenCalledWith("deck-id", "user-id");
+ });
+
+ it("returns undefined when deck not found", async () => {
+ const repo = createMockDeckRepo();
+
+ vi.mocked(repo.findById).mockResolvedValue(undefined);
+
+ expect(await repo.findById("nonexistent", "user-id")).toBeUndefined();
+ });
+
+ it("returns false when soft delete fails", async () => {
+ const repo = createMockDeckRepo();
+
+ vi.mocked(repo.softDelete).mockResolvedValue(false);
+
+ expect(await repo.softDelete("nonexistent", "user-id")).toBe(false);
+ });
+ });
+});
+
+describe("Deck interface contracts", () => {
+ it("Deck has required sync fields", () => {
+ const deck = createMockDeck();
+
+ expect(deck).toHaveProperty("syncVersion");
+ expect(deck).toHaveProperty("createdAt");
+ expect(deck).toHaveProperty("updatedAt");
+ expect(deck).toHaveProperty("deletedAt");
+ });
+
+ it("Deck has required user association", () => {
+ const deck = createMockDeck();
+
+ expect(deck).toHaveProperty("userId");
+ });
+
+ it("Deck has required configuration fields", () => {
+ const deck = createMockDeck();
+
+ expect(deck).toHaveProperty("name");
+ expect(deck).toHaveProperty("description");
+ expect(deck).toHaveProperty("newCardsPerDay");
+ });
+});
+
+describe("findByUserId ordering", () => {
+ it("returns decks ordered by createdAt", async () => {
+ const repo = createMockDeckRepo();
+
+ // Simulate decks created at different times
+ const oldDeck = createMockDeck({
+ id: "deck-old",
+ name: "Old Deck",
+ createdAt: new Date("2024-01-01"),
+ });
+ const newDeck = createMockDeck({
+ id: "deck-new",
+ name: "New Deck",
+ createdAt: new Date("2024-06-01"),
+ });
+
+ // Mock returns decks in createdAt order (oldest first)
+ vi.mocked(repo.findByUserId).mockResolvedValue([oldDeck, newDeck]);
+
+ const results = await repo.findByUserId("user-123");
+
+ expect(results).toHaveLength(2);
+ expect(results[0]?.id).toBe("deck-old");
+ expect(results[1]?.id).toBe("deck-new");
+ // Verify the order is by createdAt
+ expect(results[0]?.createdAt.getTime()).toBeLessThan(
+ results[1]?.createdAt.getTime() ?? 0,
+ );
+ });
+
+ it("returns empty array when user has no decks", async () => {
+ const repo = createMockDeckRepo();
+
+ vi.mocked(repo.findByUserId).mockResolvedValue([]);
+
+ const results = await repo.findByUserId("user-with-no-decks");
+ expect(results).toHaveLength(0);
+ });
+
+ it("returns single deck when user has one deck", async () => {
+ const repo = createMockDeckRepo();
+ const singleDeck = createMockDeck({ id: "only-deck" });
+
+ vi.mocked(repo.findByUserId).mockResolvedValue([singleDeck]);
+
+ const results = await repo.findByUserId("user-123");
+ expect(results).toHaveLength(1);
+ expect(results[0]?.id).toBe("only-deck");
+ });
+
+ it("maintains consistent ordering across multiple calls", async () => {
+ const repo = createMockDeckRepo();
+
+ const deck1 = createMockDeck({
+ id: "deck-1",
+ createdAt: new Date("2024-01-01"),
+ });
+ const deck2 = createMockDeck({
+ id: "deck-2",
+ createdAt: new Date("2024-02-01"),
+ });
+ const deck3 = createMockDeck({
+ id: "deck-3",
+ createdAt: new Date("2024-03-01"),
+ });
+
+ vi.mocked(repo.findByUserId).mockResolvedValue([deck1, deck2, deck3]);
+
+ const results1 = await repo.findByUserId("user-123");
+ const results2 = await repo.findByUserId("user-123");
+
+ // Order should be consistent
+ expect(results1.map((d) => d.id)).toEqual(results2.map((d) => d.id));
+ expect(results1.map((d) => d.id)).toEqual(["deck-1", "deck-2", "deck-3"]);
+ });
+});
diff --git a/src/server/repositories/deck.ts b/src/server/repositories/deck.ts
index 77985a7..647c5cb 100644
--- a/src/server/repositories/deck.ts
+++ b/src/server/repositories/deck.ts
@@ -8,7 +8,8 @@ export const deckRepository: DeckRepository = {
const result = await db
.select()
.from(decks)
- .where(and(eq(decks.userId, userId), isNull(decks.deletedAt)));
+ .where(and(eq(decks.userId, userId), isNull(decks.deletedAt)))
+ .orderBy(decks.createdAt);
return result;
},
diff --git a/src/server/repositories/note.test.ts b/src/server/repositories/note.test.ts
index 790ed7e..2c4b900 100644
--- a/src/server/repositories/note.test.ts
+++ b/src/server/repositories/note.test.ts
@@ -109,6 +109,7 @@ function createMockNoteRepo(): NoteRepository {
create: vi.fn(),
update: vi.fn(),
softDelete: vi.fn(),
+ createMany: vi.fn(),
};
}
@@ -455,3 +456,200 @@ describe("Card generation from Note", () => {
expect(result.cards[1]?.back).toBe("Question");
});
});
+
+describe("findByDeckId ordering", () => {
+ it("returns notes ordered by createdAt", async () => {
+ const repo = createMockNoteRepo();
+
+ const oldNote = createMockNote({
+ id: "note-old",
+ createdAt: new Date("2024-01-01"),
+ });
+ const newNote = createMockNote({
+ id: "note-new",
+ createdAt: new Date("2024-06-01"),
+ });
+
+ vi.mocked(repo.findByDeckId).mockResolvedValue([oldNote, newNote]);
+
+ const results = await repo.findByDeckId("deck-123");
+
+ expect(results).toHaveLength(2);
+ expect(results[0]?.id).toBe("note-old");
+ expect(results[1]?.id).toBe("note-new");
+ expect(results[0]?.createdAt.getTime()).toBeLessThan(
+ results[1]?.createdAt.getTime() ?? 0,
+ );
+ });
+
+ it("returns empty array when deck has no notes", async () => {
+ const repo = createMockNoteRepo();
+
+ vi.mocked(repo.findByDeckId).mockResolvedValue([]);
+
+ const results = await repo.findByDeckId("deck-with-no-notes");
+ expect(results).toHaveLength(0);
+ });
+
+ it("maintains consistent ordering across multiple calls", async () => {
+ const repo = createMockNoteRepo();
+
+ const note1 = createMockNote({
+ id: "note-1",
+ createdAt: new Date("2024-01-01"),
+ });
+ const note2 = createMockNote({
+ id: "note-2",
+ createdAt: new Date("2024-02-01"),
+ });
+ const note3 = createMockNote({
+ id: "note-3",
+ createdAt: new Date("2024-03-01"),
+ });
+
+ vi.mocked(repo.findByDeckId).mockResolvedValue([note1, note2, note3]);
+
+ const results1 = await repo.findByDeckId("deck-123");
+ const results2 = await repo.findByDeckId("deck-123");
+
+ expect(results1.map((n) => n.id)).toEqual(results2.map((n) => n.id));
+ expect(results1.map((n) => n.id)).toEqual(["note-1", "note-2", "note-3"]);
+ });
+});
+
+describe("findByIdWithFieldValues field values ordering", () => {
+ it("returns field values ordered by noteFieldTypeId", async () => {
+ const repo = createMockNoteRepo();
+
+ const fieldValueA = createMockNoteFieldValue({
+ id: "fv-1",
+ noteFieldTypeId: "field-type-aaa",
+ value: "Value A",
+ });
+ const fieldValueB = createMockNoteFieldValue({
+ id: "fv-2",
+ noteFieldTypeId: "field-type-bbb",
+ value: "Value B",
+ });
+
+ const noteWithFields = createMockNoteWithFieldValues({
+ fieldValues: [fieldValueA, fieldValueB],
+ });
+
+ vi.mocked(repo.findByIdWithFieldValues).mockResolvedValue(noteWithFields);
+
+ const result = await repo.findByIdWithFieldValues("note-id", "deck-id");
+
+ expect(result?.fieldValues).toHaveLength(2);
+ expect(result?.fieldValues[0]?.noteFieldTypeId).toBe("field-type-aaa");
+ expect(result?.fieldValues[1]?.noteFieldTypeId).toBe("field-type-bbb");
+ });
+
+ it("maintains consistent field value ordering across multiple calls", async () => {
+ const repo = createMockNoteRepo();
+
+ const fieldValues = [
+ createMockNoteFieldValue({
+ id: "fv-1",
+ noteFieldTypeId: "ft-001",
+ value: "First",
+ }),
+ createMockNoteFieldValue({
+ id: "fv-2",
+ noteFieldTypeId: "ft-002",
+ value: "Second",
+ }),
+ createMockNoteFieldValue({
+ id: "fv-3",
+ noteFieldTypeId: "ft-003",
+ value: "Third",
+ }),
+ ];
+
+ const noteWithFields = createMockNoteWithFieldValues({ fieldValues });
+
+ vi.mocked(repo.findByIdWithFieldValues).mockResolvedValue(noteWithFields);
+
+ const results1 = await repo.findByIdWithFieldValues("note-id", "deck-id");
+ const results2 = await repo.findByIdWithFieldValues("note-id", "deck-id");
+
+ expect(results1?.fieldValues.map((fv) => fv.noteFieldTypeId)).toEqual(
+ results2?.fieldValues.map((fv) => fv.noteFieldTypeId),
+ );
+ expect(results1?.fieldValues.map((fv) => fv.noteFieldTypeId)).toEqual([
+ "ft-001",
+ "ft-002",
+ "ft-003",
+ ]);
+ });
+});
+
+describe("update field values ordering", () => {
+ it("returns field values ordered by noteFieldTypeId after update", async () => {
+ const repo = createMockNoteRepo();
+
+ const fieldValueA = createMockNoteFieldValue({
+ id: "fv-1",
+ noteFieldTypeId: "field-type-aaa",
+ value: "Updated A",
+ });
+ const fieldValueB = createMockNoteFieldValue({
+ id: "fv-2",
+ noteFieldTypeId: "field-type-bbb",
+ value: "Updated B",
+ });
+
+ const updatedNote = createMockNoteWithFieldValues({
+ fieldValues: [fieldValueA, fieldValueB],
+ });
+
+ vi.mocked(repo.update).mockResolvedValue(updatedNote);
+
+ const result = await repo.update("note-id", "deck-id", {
+ "field-type-aaa": "Updated A",
+ "field-type-bbb": "Updated B",
+ });
+
+ expect(result?.fieldValues).toHaveLength(2);
+ expect(result?.fieldValues[0]?.noteFieldTypeId).toBe("field-type-aaa");
+ expect(result?.fieldValues[1]?.noteFieldTypeId).toBe("field-type-bbb");
+ });
+
+ it("maintains consistent field value ordering after update", async () => {
+ const repo = createMockNoteRepo();
+
+ const fieldValues = [
+ createMockNoteFieldValue({
+ id: "fv-1",
+ noteFieldTypeId: "ft-001",
+ value: "Updated First",
+ }),
+ createMockNoteFieldValue({
+ id: "fv-2",
+ noteFieldTypeId: "ft-002",
+ value: "Updated Second",
+ }),
+ createMockNoteFieldValue({
+ id: "fv-3",
+ noteFieldTypeId: "ft-003",
+ value: "Updated Third",
+ }),
+ ];
+
+ const updatedNote = createMockNoteWithFieldValues({ fieldValues });
+
+ vi.mocked(repo.update).mockResolvedValue(updatedNote);
+
+ const result = await repo.update("note-id", "deck-id", {
+ "ft-001": "Updated First",
+ "ft-002": "Updated Second",
+ "ft-003": "Updated Third",
+ });
+
+ expect(result?.fieldValues.map((fv) => fv.noteFieldTypeId)).toEqual([
+ "ft-001",
+ "ft-002",
+ "ft-003",
+ ]);
+ });
+});
diff --git a/src/server/repositories/note.ts b/src/server/repositories/note.ts
index 52cbf9b..6f607cf 100644
--- a/src/server/repositories/note.ts
+++ b/src/server/repositories/note.ts
@@ -9,6 +9,8 @@ import {
noteTypes,
} from "../db/schema.js";
import type {
+ BulkCreateNoteInput,
+ BulkCreateNoteResult,
Card,
CreateNoteResult,
Note,
@@ -22,7 +24,8 @@ export const noteRepository: NoteRepository = {
const result = await db
.select()
.from(notes)
- .where(and(eq(notes.deckId, deckId), isNull(notes.deletedAt)));
+ .where(and(eq(notes.deckId, deckId), isNull(notes.deletedAt)))
+ .orderBy(notes.createdAt);
return result;
},
@@ -52,7 +55,8 @@ export const noteRepository: NoteRepository = {
const fieldValuesResult = await db
.select()
.from(noteFieldValues)
- .where(eq(noteFieldValues.noteId, id));
+ .where(eq(noteFieldValues.noteId, id))
+ .orderBy(noteFieldValues.noteFieldTypeId);
return {
...note,
@@ -219,7 +223,8 @@ export const noteRepository: NoteRepository = {
const allFieldValues = await db
.select()
.from(noteFieldValues)
- .where(eq(noteFieldValues.noteId, id));
+ .where(eq(noteFieldValues.noteId, id))
+ .orderBy(noteFieldValues.noteFieldTypeId);
return {
...updatedNote,
@@ -262,6 +267,129 @@ export const noteRepository: NoteRepository = {
return result.length > 0;
},
+
+ async createMany(
+ deckId: string,
+ notesInput: BulkCreateNoteInput[],
+ ): Promise<BulkCreateNoteResult> {
+ const failed: { index: number; error: string }[] = [];
+ let created = 0;
+
+ // Pre-fetch all note types and their field types for validation
+ const noteTypeCache = new Map<
+ string,
+ {
+ noteType: {
+ frontTemplate: string;
+ backTemplate: string;
+ isReversible: boolean;
+ };
+ fieldTypes: { id: string; name: string }[];
+ }
+ >();
+
+ for (let i = 0; i < notesInput.length; i++) {
+ const input = notesInput[i];
+ if (!input) continue;
+
+ try {
+ // Get note type from cache or fetch
+ let cached = noteTypeCache.get(input.noteTypeId);
+ if (!cached) {
+ const noteType = await db
+ .select()
+ .from(noteTypes)
+ .where(
+ and(
+ eq(noteTypes.id, input.noteTypeId),
+ isNull(noteTypes.deletedAt),
+ ),
+ );
+
+ if (!noteType[0]) {
+ failed.push({ index: i, error: "Note type not found" });
+ continue;
+ }
+
+ const fieldTypes = await db
+ .select()
+ .from(noteFieldTypes)
+ .where(
+ and(
+ eq(noteFieldTypes.noteTypeId, input.noteTypeId),
+ isNull(noteFieldTypes.deletedAt),
+ ),
+ )
+ .orderBy(noteFieldTypes.order);
+
+ cached = { noteType: noteType[0], fieldTypes };
+ noteTypeCache.set(input.noteTypeId, cached);
+ }
+
+ // Create note
+ const [note] = await db
+ .insert(notes)
+ .values({
+ deckId,
+ noteTypeId: input.noteTypeId,
+ })
+ .returning();
+
+ if (!note) {
+ failed.push({ index: i, error: "Failed to create note" });
+ continue;
+ }
+
+ // Create field values
+ const fieldValuesResult: NoteFieldValue[] = [];
+ for (const fieldType of cached.fieldTypes) {
+ const value = input.fields[fieldType.id] ?? "";
+ const [fieldValue] = await db
+ .insert(noteFieldValues)
+ .values({
+ noteId: note.id,
+ noteFieldTypeId: fieldType.id,
+ value,
+ })
+ .returning();
+ if (fieldValue) {
+ fieldValuesResult.push(fieldValue);
+ }
+ }
+
+ // Create normal card
+ await createCardForNote(
+ deckId,
+ note.id,
+ cached.noteType,
+ fieldValuesResult,
+ cached.fieldTypes,
+ false,
+ );
+
+ // Create reversed card if reversible
+ if (cached.noteType.isReversible) {
+ await createCardForNote(
+ deckId,
+ note.id,
+ cached.noteType,
+ fieldValuesResult,
+ cached.fieldTypes,
+ true,
+ );
+ }
+
+ created++;
+ } catch (error) {
+ failed.push({
+ index: i,
+ error: error instanceof Error ? error.message : "Unknown error",
+ });
+ }
+ }
+
+ return { created, failed };
+ },
};
async function createCardForNote(
diff --git a/src/server/repositories/noteType.test.ts b/src/server/repositories/noteType.test.ts
index 22e8839..fdb9d5c 100644
--- a/src/server/repositories/noteType.test.ts
+++ b/src/server/repositories/noteType.test.ts
@@ -356,3 +356,84 @@ describe("NoteFieldType deletion constraints", () => {
});
});
});
+
+describe("findByUserId ordering", () => {
+ it("returns note types ordered by createdAt", async () => {
+ const repo = createMockNoteTypeRepo();
+
+ const oldNoteType = createMockNoteType({
+ id: "note-type-old",
+ name: "Old Type",
+ createdAt: new Date("2024-01-01"),
+ });
+ const newNoteType = createMockNoteType({
+ id: "note-type-new",
+ name: "New Type",
+ createdAt: new Date("2024-06-01"),
+ });
+
+ vi.mocked(repo.findByUserId).mockResolvedValue([oldNoteType, newNoteType]);
+
+ const results = await repo.findByUserId("user-123");
+
+ expect(results).toHaveLength(2);
+ expect(results[0]?.id).toBe("note-type-old");
+ expect(results[1]?.id).toBe("note-type-new");
+ expect(results[0]?.createdAt.getTime()).toBeLessThan(
+ results[1]?.createdAt.getTime() ?? 0,
+ );
+ });
+
+ it("returns empty array when user has no note types", async () => {
+ const repo = createMockNoteTypeRepo();
+
+ vi.mocked(repo.findByUserId).mockResolvedValue([]);
+
+ const results = await repo.findByUserId("user-with-no-note-types");
+ expect(results).toHaveLength(0);
+ });
+
+ it("returns single note type when user has one", async () => {
+ const repo = createMockNoteTypeRepo();
+ const singleNoteType = createMockNoteType({ id: "only-note-type" });
+
+ vi.mocked(repo.findByUserId).mockResolvedValue([singleNoteType]);
+
+ const results = await repo.findByUserId("user-123");
+ expect(results).toHaveLength(1);
+ expect(results[0]?.id).toBe("only-note-type");
+ });
+
+ it("maintains consistent ordering across multiple calls", async () => {
+ const repo = createMockNoteTypeRepo();
+
+ const noteType1 = createMockNoteType({
+ id: "note-type-1",
+ createdAt: new Date("2024-01-01"),
+ });
+ const noteType2 = createMockNoteType({
+ id: "note-type-2",
+ createdAt: new Date("2024-02-01"),
+ });
+ const noteType3 = createMockNoteType({
+ id: "note-type-3",
+ createdAt: new Date("2024-03-01"),
+ });
+
+ vi.mocked(repo.findByUserId).mockResolvedValue([
+ noteType1,
+ noteType2,
+ noteType3,
+ ]);
+
+ const results1 = await repo.findByUserId("user-123");
+ const results2 = await repo.findByUserId("user-123");
+
+ expect(results1.map((nt) => nt.id)).toEqual(results2.map((nt) => nt.id));
+ expect(results1.map((nt) => nt.id)).toEqual([
+ "note-type-1",
+ "note-type-2",
+ "note-type-3",
+ ]);
+ });
+});
diff --git a/src/server/repositories/noteType.ts b/src/server/repositories/noteType.ts
index 25aa8ff..06c8834 100644
--- a/src/server/repositories/noteType.ts
+++ b/src/server/repositories/noteType.ts
@@ -14,7 +14,8 @@ export const noteTypeRepository: NoteTypeRepository = {
const result = await db
.select()
.from(noteTypes)
- .where(and(eq(noteTypes.userId, userId), isNull(noteTypes.deletedAt)));
+ .where(and(eq(noteTypes.userId, userId), isNull(noteTypes.deletedAt)))
+ .orderBy(noteTypes.createdAt);
return result;
},
diff --git a/src/server/repositories/sync.test.ts b/src/server/repositories/sync.test.ts
new file mode 100644
index 0000000..ce59cb5
--- /dev/null
+++ b/src/server/repositories/sync.test.ts
@@ -0,0 +1,397 @@
+import { describe, expect, it, vi } from "vitest";
+import type { SyncPullResult, SyncPushResult, SyncRepository } from "./sync.js";
+import type {
+ Card,
+ Deck,
+ Note,
+ NoteFieldType,
+ NoteFieldValue,
+ NoteType,
+ ReviewLog,
+} from "./types.js";
+
+function createMockDeck(overrides: Partial<Deck> = {}): Deck {
+ return {
+ id: "deck-uuid-123",
+ userId: "user-uuid-123",
+ name: "Test Deck",
+ description: null,
+ newCardsPerDay: 20,
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ deletedAt: null,
+ syncVersion: 1,
+ ...overrides,
+ };
+}
+
+function createMockCard(overrides: Partial<Card> = {}): Card {
+ return {
+ id: "card-uuid-123",
+ deckId: "deck-uuid-123",
+ noteId: "note-uuid-123",
+ isReversed: false,
+ front: "Front text",
+ back: "Back text",
+ state: 0,
+ 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: 1,
+ ...overrides,
+ };
+}
+
+function createMockNote(overrides: Partial<Note> = {}): Note {
+ return {
+ id: "note-uuid-123",
+ deckId: "deck-uuid-123",
+ noteTypeId: "note-type-uuid-123",
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ deletedAt: null,
+ syncVersion: 1,
+ ...overrides,
+ };
+}
+
+function createMockNoteType(overrides: Partial<NoteType> = {}): NoteType {
+ return {
+ id: "note-type-uuid-123",
+ userId: "user-uuid-123",
+ name: "Basic",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: false,
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ deletedAt: null,
+ syncVersion: 1,
+ ...overrides,
+ };
+}
+
+function createMockNoteFieldType(
+ overrides: Partial<NoteFieldType> = {},
+): NoteFieldType {
+ return {
+ id: "field-type-uuid-123",
+ noteTypeId: "note-type-uuid-123",
+ name: "Front",
+ order: 0,
+ fieldType: "text",
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ deletedAt: null,
+ syncVersion: 1,
+ ...overrides,
+ };
+}
+
+function createMockNoteFieldValue(
+ overrides: Partial<NoteFieldValue> = {},
+): NoteFieldValue {
+ return {
+ id: "field-value-uuid-123",
+ noteId: "note-uuid-123",
+ noteFieldTypeId: "field-type-uuid-123",
+ value: "Test value",
+ createdAt: new Date("2024-01-01"),
+ updatedAt: new Date("2024-01-01"),
+ syncVersion: 1,
+ ...overrides,
+ };
+}
+
+function createMockReviewLog(overrides: Partial<ReviewLog> = {}): ReviewLog {
+ return {
+ id: "review-log-uuid-123",
+ cardId: "card-uuid-123",
+ userId: "user-uuid-123",
+ rating: 3,
+ state: 2,
+ scheduledDays: 1,
+ elapsedDays: 0,
+ reviewedAt: new Date("2024-01-01"),
+ durationMs: 5000,
+ syncVersion: 1,
+ ...overrides,
+ };
+}
+
+function createMockSyncRepo(): SyncRepository {
+ return {
+ pushChanges: vi.fn(),
+ pullChanges: vi.fn(),
+ };
+}
+
+describe("SyncRepository mock factory", () => {
+ describe("createMockSyncRepo", () => {
+ it("creates a repository with all required methods", () => {
+ const repo = createMockSyncRepo();
+
+ expect(repo.pushChanges).toBeDefined();
+ expect(repo.pullChanges).toBeDefined();
+ });
+
+ it("methods are mockable for pushChanges", async () => {
+ const repo = createMockSyncRepo();
+ const mockResult: SyncPushResult = {
+ decks: [{ id: "deck-1", syncVersion: 1 }],
+ cards: [],
+ reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ crdtChanges: [],
+ conflicts: {
+ decks: [],
+ cards: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ },
+ };
+
+ vi.mocked(repo.pushChanges).mockResolvedValue(mockResult);
+
+ const result = await repo.pushChanges("user-123", {
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ });
+
+ expect(result.decks).toHaveLength(1);
+ expect(repo.pushChanges).toHaveBeenCalledWith(
+ "user-123",
+ expect.any(Object),
+ );
+ });
+
+ it("methods are mockable for pullChanges", async () => {
+ const repo = createMockSyncRepo();
+ const mockResult: SyncPullResult = {
+ decks: [createMockDeck()],
+ cards: [createMockCard()],
+ reviewLogs: [createMockReviewLog()],
+ noteTypes: [createMockNoteType()],
+ noteFieldTypes: [createMockNoteFieldType()],
+ notes: [createMockNote()],
+ noteFieldValues: [createMockNoteFieldValue()],
+ crdtChanges: [],
+ currentSyncVersion: 5,
+ };
+
+ vi.mocked(repo.pullChanges).mockResolvedValue(mockResult);
+
+ const result = await repo.pullChanges("user-123", { lastSyncVersion: 0 });
+
+ expect(result.decks).toHaveLength(1);
+ expect(result.cards).toHaveLength(1);
+ expect(result.currentSyncVersion).toBe(5);
+ expect(repo.pullChanges).toHaveBeenCalledWith("user-123", {
+ lastSyncVersion: 0,
+ });
+ });
+ });
+});
+
+describe("SyncPullResult ordering", () => {
+ describe("pullChanges returns entities ordered by id", () => {
+ it("returns cards ordered by id", async () => {
+ const repo = createMockSyncRepo();
+
+ const cardA = createMockCard({ id: "card-aaa" });
+ const cardB = createMockCard({ id: "card-bbb" });
+ const cardC = createMockCard({ id: "card-ccc" });
+
+ const mockResult: SyncPullResult = {
+ decks: [],
+ cards: [cardA, cardB, cardC],
+ reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ crdtChanges: [],
+ currentSyncVersion: 1,
+ };
+
+ vi.mocked(repo.pullChanges).mockResolvedValue(mockResult);
+
+ const result = await repo.pullChanges("user-123", { lastSyncVersion: 0 });
+
+ expect(result.cards).toHaveLength(3);
+ expect(result.cards[0]?.id).toBe("card-aaa");
+ expect(result.cards[1]?.id).toBe("card-bbb");
+ expect(result.cards[2]?.id).toBe("card-ccc");
+ });
+
+ it("returns notes ordered by id", async () => {
+ const repo = createMockSyncRepo();
+
+ const noteA = createMockNote({ id: "note-aaa" });
+ const noteB = createMockNote({ id: "note-bbb" });
+ const noteC = createMockNote({ id: "note-ccc" });
+
+ const mockResult: SyncPullResult = {
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [noteA, noteB, noteC],
+ noteFieldValues: [],
+ crdtChanges: [],
+ currentSyncVersion: 1,
+ };
+
+ vi.mocked(repo.pullChanges).mockResolvedValue(mockResult);
+
+ const result = await repo.pullChanges("user-123", { lastSyncVersion: 0 });
+
+ expect(result.notes).toHaveLength(3);
+ expect(result.notes[0]?.id).toBe("note-aaa");
+ expect(result.notes[1]?.id).toBe("note-bbb");
+ expect(result.notes[2]?.id).toBe("note-ccc");
+ });
+
+ it("returns noteFieldTypes ordered by id", async () => {
+ const repo = createMockSyncRepo();
+
+ const fieldTypeA = createMockNoteFieldType({ id: "ft-aaa" });
+ const fieldTypeB = createMockNoteFieldType({ id: "ft-bbb" });
+ const fieldTypeC = createMockNoteFieldType({ id: "ft-ccc" });
+
+ const mockResult: SyncPullResult = {
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [fieldTypeA, fieldTypeB, fieldTypeC],
+ notes: [],
+ noteFieldValues: [],
+ crdtChanges: [],
+ currentSyncVersion: 1,
+ };
+
+ vi.mocked(repo.pullChanges).mockResolvedValue(mockResult);
+
+ const result = await repo.pullChanges("user-123", { lastSyncVersion: 0 });
+
+ expect(result.noteFieldTypes).toHaveLength(3);
+ expect(result.noteFieldTypes[0]?.id).toBe("ft-aaa");
+ expect(result.noteFieldTypes[1]?.id).toBe("ft-bbb");
+ expect(result.noteFieldTypes[2]?.id).toBe("ft-ccc");
+ });
+
+ it("returns noteFieldValues ordered by id", async () => {
+ const repo = createMockSyncRepo();
+
+ const fieldValueA = createMockNoteFieldValue({ id: "fv-aaa" });
+ const fieldValueB = createMockNoteFieldValue({ id: "fv-bbb" });
+ const fieldValueC = createMockNoteFieldValue({ id: "fv-ccc" });
+
+ const mockResult: SyncPullResult = {
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [fieldValueA, fieldValueB, fieldValueC],
+ crdtChanges: [],
+ currentSyncVersion: 1,
+ };
+
+ vi.mocked(repo.pullChanges).mockResolvedValue(mockResult);
+
+ const result = await repo.pullChanges("user-123", { lastSyncVersion: 0 });
+
+ expect(result.noteFieldValues).toHaveLength(3);
+ expect(result.noteFieldValues[0]?.id).toBe("fv-aaa");
+ expect(result.noteFieldValues[1]?.id).toBe("fv-bbb");
+ expect(result.noteFieldValues[2]?.id).toBe("fv-ccc");
+ });
+
+ it("maintains consistent ordering across multiple calls", async () => {
+ const repo = createMockSyncRepo();
+
+ const cards = [
+ createMockCard({ id: "card-001" }),
+ createMockCard({ id: "card-002" }),
+ createMockCard({ id: "card-003" }),
+ ];
+
+ const mockResult: SyncPullResult = {
+ decks: [],
+ cards,
+ reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ crdtChanges: [],
+ currentSyncVersion: 1,
+ };
+
+ vi.mocked(repo.pullChanges).mockResolvedValue(mockResult);
+
+ const result1 = await repo.pullChanges("user-123", {
+ lastSyncVersion: 0,
+ });
+ const result2 = await repo.pullChanges("user-123", {
+ lastSyncVersion: 0,
+ });
+
+ expect(result1.cards.map((c) => c.id)).toEqual(
+ result2.cards.map((c) => c.id),
+ );
+ expect(result1.cards.map((c) => c.id)).toEqual([
+ "card-001",
+ "card-002",
+ "card-003",
+ ]);
+ });
+
+ it("returns empty arrays when no entities to pull", async () => {
+ const repo = createMockSyncRepo();
+
+ const mockResult: SyncPullResult = {
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ crdtChanges: [],
+ currentSyncVersion: 0,
+ };
+
+ vi.mocked(repo.pullChanges).mockResolvedValue(mockResult);
+
+ const result = await repo.pullChanges("user-123", { lastSyncVersion: 0 });
+
+ expect(result.cards).toHaveLength(0);
+ expect(result.notes).toHaveLength(0);
+ expect(result.noteFieldTypes).toHaveLength(0);
+ expect(result.noteFieldValues).toHaveLength(0);
+ });
+ });
+});
diff --git a/src/server/repositories/sync.ts b/src/server/repositories/sync.ts
index 188bd1b..ca4c208 100644
--- a/src/server/repositories/sync.ts
+++ b/src/server/repositories/sync.ts
@@ -896,7 +896,8 @@ export const syncRepository: SyncRepository = {
const cardResults = await db
.select()
.from(cards)
- .where(gt(cards.syncVersion, lastSyncVersion));
+ .where(gt(cards.syncVersion, lastSyncVersion))
+ .orderBy(cards.id);
// Filter cards that belong to user's decks
pulledCards = cardResults.filter((c) => deckIdList.includes(c.deckId));
@@ -938,7 +939,8 @@ export const syncRepository: SyncRepository = {
const fieldTypeResults = await db
.select()
.from(noteFieldTypes)
- .where(gt(noteFieldTypes.syncVersion, lastSyncVersion));
+ .where(gt(noteFieldTypes.syncVersion, lastSyncVersion))
+ .orderBy(noteFieldTypes.id);
pulledNoteFieldTypes = fieldTypeResults.filter((ft) =>
noteTypeIdList.includes(ft.noteTypeId),
@@ -951,7 +953,8 @@ export const syncRepository: SyncRepository = {
const noteResults = await db
.select()
.from(notes)
- .where(gt(notes.syncVersion, lastSyncVersion));
+ .where(gt(notes.syncVersion, lastSyncVersion))
+ .orderBy(notes.id);
pulledNotes = noteResults.filter((n) => deckIdList.includes(n.deckId));
}
@@ -973,7 +976,8 @@ export const syncRepository: SyncRepository = {
const fieldValueResults = await db
.select()
.from(noteFieldValues)
- .where(gt(noteFieldValues.syncVersion, lastSyncVersion));
+ .where(gt(noteFieldValues.syncVersion, lastSyncVersion))
+ .orderBy(noteFieldValues.id);
pulledNoteFieldValues = fieldValueResults.filter((fv) =>
allUserNoteIds.includes(fv.noteId),
diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts
index a986cad..cb3a287 100644
--- a/src/server/repositories/types.ts
+++ b/src/server/repositories/types.ts
@@ -147,6 +147,7 @@ export interface CardRepository {
softDelete(id: string, deckId: string): Promise<boolean>;
softDeleteByNoteId(noteId: string): Promise<boolean>;
findDueCards(deckId: string, now: Date, limit: number): Promise<Card[]>;
+ countDueCards(deckId: string, now: Date): Promise<number>;
findDueCardsWithNoteData(
deckId: string,
now: Date,
@@ -310,6 +311,21 @@ export interface CreateNoteResult {
cards: Card[];
}
+export interface BulkCreateNoteInput {
+ noteTypeId: string;
+ fields: Record<string, string>;
+}
+
+export interface BulkCreateNoteFailure {
+ index: number;
+ error: string;
+}
+
+export interface BulkCreateNoteResult {
+ created: number;
+ failed: BulkCreateNoteFailure[];
+}
+
export interface NoteRepository {
findByDeckId(deckId: string): Promise<Note[]>;
findById(id: string, deckId: string): Promise<Note | undefined>;
@@ -330,4 +346,8 @@ export interface NoteRepository {
fields: Record<string, string>,
): Promise<NoteWithFieldValues | undefined>;
softDelete(id: string, deckId: string): Promise<boolean>;
+ createMany(
+ deckId: string,
+ notes: BulkCreateNoteInput[],
+ ): Promise<BulkCreateNoteResult>;
}