aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-31 20:34:27 +0900
committernsfisis <nsfisis@gmail.com>2025-12-31 20:34:27 +0900
commite3a84df9fcae8f25c8ea1d527638c2bfaafb942e (patch)
tree605f909c78e8b4510b7110d57749c1c0fe566360
parentb69fd1353c449baa3262016c2bb8f653932bd932 (diff)
downloadkioku-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.md34
-rw-r--r--src/server/repositories/card.test.ts137
-rw-r--r--src/server/repositories/card.ts6
-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.ts197
-rw-r--r--src/server/repositories/note.ts9
-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
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),