diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-31 20:34:27 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-31 20:34:27 +0900 |
| commit | e3a84df9fcae8f25c8ea1d527638c2bfaafb942e (patch) | |
| tree | 605f909c78e8b4510b7110d57749c1c0fe566360 | |
| parent | b69fd1353c449baa3262016c2bb8f653932bd932 (diff) | |
| download | kioku-e3a84df9fcae8f25c8ea1d527638c2bfaafb942e.tar.gz kioku-e3a84df9fcae8f25c8ea1d527638c2bfaafb942e.tar.zst kioku-e3a84df9fcae8f25c8ea1d527638c2bfaafb942e.zip | |
feat(db): add ORDER BY to repository SELECT queries
Ensures deterministic ordering for all multi-row SELECT queries:
- deck/note/noteType findByUserId/findByDeckId: order by createdAt
- card findByNoteId: order by isReversed (normal card first)
- note field values: order by noteFieldTypeId
- sync pull queries: order by id
This guarantees consistent UI display and sync results regardless
of PostgreSQL's internal row ordering.
🤖 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 | 34 | ||||
| -rw-r--r-- | src/server/repositories/card.test.ts | 137 | ||||
| -rw-r--r-- | src/server/repositories/card.ts | 6 | ||||
| -rw-r--r-- | src/server/repositories/deck.test.ts | 212 | ||||
| -rw-r--r-- | src/server/repositories/deck.ts | 3 | ||||
| -rw-r--r-- | src/server/repositories/note.test.ts | 197 | ||||
| -rw-r--r-- | src/server/repositories/note.ts | 9 | ||||
| -rw-r--r-- | src/server/repositories/noteType.test.ts | 81 | ||||
| -rw-r--r-- | src/server/repositories/noteType.ts | 3 | ||||
| -rw-r--r-- | src/server/repositories/sync.test.ts | 397 | ||||
| -rw-r--r-- | src/server/repositories/sync.ts | 12 |
11 files changed, 1046 insertions, 45 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md deleted file mode 100644 index 3347d0f..0000000 --- a/docs/dev/roadmap.md +++ /dev/null @@ -1,34 +0,0 @@ -# Kioku Development Roadmap - -## Add ORDER BY to SELECT Queries - -複数行を返すSELECTクエリにORDER BYを追加し、実行結果の順序を決定的にする。 - -**Background:** -- PostgreSQLはORDER BYがない場合、行の返却順序を保証しない -- UI表示やSync結果の一貫性のため、明示的なソート順が必要 - -### Phase 1: Core Repository Queries - -- [ ] `src/server/repositories/deck.ts` - - `findByUserId()`: `.orderBy(decks.createdAt)` 追加 -- [ ] `src/server/repositories/note.ts` - - `findByDeckId()`: `.orderBy(notes.createdAt)` 追加 - - `findByIdWithFieldValues()` 内のfieldValues取得: `.orderBy(noteFieldValues.noteFieldTypeId)` 追加 - - `update()` 内のallFieldValues取得: `.orderBy(noteFieldValues.noteFieldTypeId)` 追加 -- [ ] `src/server/repositories/noteType.ts` - - `findByUserId()`: `.orderBy(noteTypes.createdAt)` 追加 -- [ ] `src/server/repositories/card.ts` - - `findByDeckId()`: `.orderBy(cards.createdAt)` 追加 - - `findByNoteId()`: `.orderBy(cards.isReversed)` 追加 (通常カードを先に) - -### Phase 2: Sync Repository Queries - -- [ ] `src/server/repositories/sync.ts` - - pull系クエリ (cards, noteFieldTypes, notes, noteFieldValues): `.orderBy(*.id)` 追加 - -### Phase 3: Verification - -- [ ] `pnpm typecheck` 実行 -- [ ] `pnpm lint` 実行 -- [ ] `pnpm test` 実行 diff --git a/src/server/repositories/card.test.ts b/src/server/repositories/card.test.ts index 4263dad..0a46a76 100644 --- a/src/server/repositories/card.test.ts +++ b/src/server/repositories/card.test.ts @@ -504,3 +504,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..04425a2 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; }, 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..0fe1c9f 100644 --- a/src/server/repositories/note.test.ts +++ b/src/server/repositories/note.test.ts @@ -455,3 +455,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..eb1aa28 100644 --- a/src/server/repositories/note.ts +++ b/src/server/repositories/note.ts @@ -22,7 +22,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 +53,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 +221,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, 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), |
