diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-31 14:19:22 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-31 14:19:22 +0900 |
| commit | b074a4901c630ee5c5f7dcff79fa6ff911a14ded (patch) | |
| tree | a52b738974393e31678c85e37756004d1b547823 /src/server | |
| parent | 29caaa7aaf14a41dad3d345cd29b319fff6e1305 (diff) | |
| download | kioku-b074a4901c630ee5c5f7dcff79fa6ff911a14ded.tar.gz kioku-b074a4901c630ee5c5f7dcff79fa6ff911a14ded.tar.zst kioku-b074a4901c630ee5c5f7dcff79fa6ff911a14ded.zip | |
feat(schema): make note_id and is_reversed NOT NULL
All cards now require note association - legacy card support removed.
This aligns with the note-based card architecture introduced in Phase 8.
- Add database migration for NOT NULL constraints
- Update client Dexie schema to version 3
- Remove LegacyCardItem component and legacy card handling
- Update sync schemas and type definitions
- Update all tests to use note-based cards
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/db/schema.ts | 6 | ||||
| -rw-r--r-- | src/server/repositories/card.test.ts | 69 | ||||
| -rw-r--r-- | src/server/repositories/card.ts | 56 | ||||
| -rw-r--r-- | src/server/repositories/note.test.ts | 4 | ||||
| -rw-r--r-- | src/server/repositories/sync.ts | 4 | ||||
| -rw-r--r-- | src/server/repositories/types.ts | 14 | ||||
| -rw-r--r-- | src/server/routes/cards.test.ts | 7 | ||||
| -rw-r--r-- | src/server/routes/study.test.ts | 15 | ||||
| -rw-r--r-- | src/server/routes/sync.test.ts | 16 | ||||
| -rw-r--r-- | src/server/routes/sync.ts | 4 |
10 files changed, 81 insertions, 114 deletions
diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index bd3d396..0471a92 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -153,8 +153,10 @@ export const cards = pgTable("cards", { deckId: uuid("deck_id") .notNull() .references(() => decks.id), - noteId: uuid("note_id").references(() => notes.id), - isReversed: boolean("is_reversed"), + noteId: uuid("note_id") + .notNull() + .references(() => notes.id), + isReversed: boolean("is_reversed").notNull(), front: text("front").notNull(), back: text("back").notNull(), diff --git a/src/server/repositories/card.test.ts b/src/server/repositories/card.test.ts index 9d7ffa6..98913e9 100644 --- a/src/server/repositories/card.test.ts +++ b/src/server/repositories/card.test.ts @@ -12,8 +12,8 @@ function createMockCard(overrides: Partial<Card> = {}): Card { return { id: "card-uuid-123", deckId: "deck-uuid-123", - noteId: null, - isReversed: null, + noteId: "note-uuid-123", + isReversed: false, front: "Front text", back: "Back text", state: 0, @@ -89,15 +89,14 @@ function createMockCardWithNoteData( function createMockCardForStudy( overrides: Partial<CardForStudy> = {}, ): CardForStudy { - const card = createMockCard({ - noteId: overrides.noteType ? "note-uuid-123" : null, - isReversed: overrides.noteType ? false : null, - ...overrides, - }); + const card = createMockCard(overrides); return { ...card, - noteType: overrides.noteType ?? null, - fieldValuesMap: overrides.fieldValuesMap ?? {}, + noteType: overrides.noteType ?? { + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + }, + fieldValuesMap: overrides.fieldValuesMap ?? { Front: "Q", Back: "A" }, }; } @@ -125,8 +124,8 @@ describe("CardRepository mock factory", () => { expect(card.id).toBe("card-uuid-123"); expect(card.deckId).toBe("deck-uuid-123"); - expect(card.noteId).toBeNull(); - expect(card.isReversed).toBeNull(); + expect(card.noteId).toBe("note-uuid-123"); + expect(card.isReversed).toBe(false); expect(card.front).toBe("Front text"); expect(card.back).toBe("Back text"); expect(card.state).toBe(0); @@ -205,17 +204,13 @@ describe("CardRepository mock factory", () => { expect(cardWithNote.fieldValues[0]?.value).toBe("日本語"); }); - it("can represent legacy card with null note", () => { - // For legacy cards without notes, we explicitly construct the type - const legacyCard: CardWithNoteData = { - ...createMockCard({ noteId: null, isReversed: null }), - note: null, - fieldValues: [], - }; - - expect(legacyCard.noteId).toBeNull(); - expect(legacyCard.note).toBeNull(); - expect(legacyCard.fieldValues).toHaveLength(0); + it("card always has note association", () => { + // All cards now require note association + const cardWithNote = createMockCardWithNoteData(); + + expect(cardWithNote.noteId).toBe("note-uuid-123"); + expect(cardWithNote.note).not.toBeNull(); + expect(cardWithNote.fieldValues).toHaveLength(2); }); }); @@ -392,41 +387,39 @@ describe("Card interface contracts", () => { expect(cardForStudy.fieldValuesMap.Front).toBe("Question"); }); - it("CardForStudy can represent legacy card with null noteType", () => { - const legacyCard = createMockCardForStudy({ - front: "Legacy Question", - back: "Legacy Answer", + it("CardForStudy has required note data", () => { + const cardForStudy = createMockCardForStudy({ + front: "Question", + back: "Answer", }); - expect(legacyCard.noteId).toBeNull(); - expect(legacyCard.noteType).toBeNull(); - expect(legacyCard.fieldValuesMap).toEqual({}); - expect(legacyCard.front).toBe("Legacy Question"); - expect(legacyCard.back).toBe("Legacy Answer"); + expect(cardForStudy.noteId).toBe("note-uuid-123"); + expect(cardForStudy.noteType).not.toBeNull(); + expect(cardForStudy.noteType.frontTemplate).toBe("{{Front}}"); + expect(cardForStudy.fieldValuesMap).toEqual({ Front: "Q", Back: "A" }); }); }); describe("Card and Note relationship", () => { - it("legacy card has null noteId and isReversed", () => { + it("card has required noteId and isReversed", () => { const card = createMockCard(); - expect(card.noteId).toBeNull(); - expect(card.isReversed).toBeNull(); + expect(card.noteId).toBe("note-uuid-123"); + expect(card.isReversed).toBe(false); }); - it("note-based card has noteId and isReversed set", () => { + it("card with explicit note data", () => { const card = createMockCard({ - noteId: "note-uuid-123", + noteId: "different-note-id", isReversed: false, }); - expect(card.noteId).toBe("note-uuid-123"); + expect(card.noteId).toBe("different-note-id"); expect(card.isReversed).toBe(false); }); it("reversed card has isReversed true", () => { const card = createMockCard({ - noteId: "note-uuid-123", isReversed: true, }); diff --git a/src/server/repositories/card.ts b/src/server/repositories/card.ts index 7116642..4c4fc81 100644 --- a/src/server/repositories/card.ts +++ b/src/server/repositories/card.ts @@ -47,20 +47,15 @@ export const cardRepository: CardRepository = { return undefined; } - if (!card.noteId) { - return { - ...card, - note: null, - fieldValues: [], - }; - } - const noteResult = await db .select() .from(notes) .where(and(eq(notes.id, card.noteId), isNull(notes.deletedAt))); - const note = noteResult[0] ?? null; + const note = noteResult[0]; + if (!note) { + return undefined; + } const fieldValuesResult = await db .select() @@ -85,6 +80,8 @@ export const cardRepository: CardRepository = { async create( deckId: string, data: { + noteId: string; + isReversed: boolean; front: string; back: string; }, @@ -93,6 +90,8 @@ export const cardRepository: CardRepository = { .insert(cards) .values({ deckId, + noteId: data.noteId, + isReversed: data.isReversed, front: data.front, back: data.back, state: CardState.New, @@ -200,21 +199,16 @@ export const cardRepository: CardRepository = { const cardsWithNoteData: CardWithNoteData[] = []; for (const card of dueCards) { - if (!card.noteId) { - cardsWithNoteData.push({ - ...card, - note: null, - fieldValues: [], - }); - continue; - } - const noteResult = await db .select() .from(notes) .where(and(eq(notes.id, card.noteId), isNull(notes.deletedAt))); - const note = noteResult[0] ?? null; + const note = noteResult[0]; + if (!note) { + // Note was deleted, skip this card + continue; + } const fieldValuesResult = await db .select() @@ -241,16 +235,6 @@ export const cardRepository: CardRepository = { const cardsForStudy: CardForStudy[] = []; for (const card of dueCards) { - // Legacy card (no note association) - if (!card.noteId) { - cardsForStudy.push({ - ...card, - noteType: null, - fieldValuesMap: {}, - }); - continue; - } - // Fetch note to get noteTypeId const noteResult = await db .select() @@ -259,12 +243,7 @@ export const cardRepository: CardRepository = { const note = noteResult[0]; if (!note) { - // Note was deleted, treat as legacy card - cardsForStudy.push({ - ...card, - noteType: null, - fieldValuesMap: {}, - }); + // Note was deleted, skip this card continue; } @@ -281,12 +260,7 @@ export const cardRepository: CardRepository = { const noteType = noteTypeResult[0]; if (!noteType) { - // Note type was deleted, treat as legacy card - cardsForStudy.push({ - ...card, - noteType: null, - fieldValuesMap: {}, - }); + // Note type was deleted, skip this card continue; } diff --git a/src/server/repositories/note.test.ts b/src/server/repositories/note.test.ts index 70dcb4a..cc1a9ae 100644 --- a/src/server/repositories/note.test.ts +++ b/src/server/repositories/note.test.ts @@ -40,8 +40,8 @@ function createMockCard(overrides: Partial<Card> = {}): Card { return { id: "card-uuid-123", deckId: "deck-uuid-123", - noteId: null, - isReversed: null, + noteId: "note-uuid-123", + isReversed: false, front: "Front text", back: "Back text", state: 0, diff --git a/src/server/repositories/sync.ts b/src/server/repositories/sync.ts index 8c4fd25..59a195a 100644 --- a/src/server/repositories/sync.ts +++ b/src/server/repositories/sync.ts @@ -45,8 +45,8 @@ export interface SyncDeckData { export interface SyncCardData { id: string; deckId: string; - noteId: string | null; - isReversed: boolean | null; + noteId: string; + isReversed: boolean; front: string; back: string; state: number; diff --git a/src/server/repositories/types.ts b/src/server/repositories/types.ts index c864be0..a986cad 100644 --- a/src/server/repositories/types.ts +++ b/src/server/repositories/types.ts @@ -81,8 +81,8 @@ export interface DeckRepository { export interface Card { id: string; deckId: string; - noteId: string | null; - isReversed: boolean | null; + noteId: string; + isReversed: boolean; front: string; back: string; @@ -104,22 +104,20 @@ export interface Card { } export interface CardWithNoteData extends Card { - note: Note | null; + note: Note; fieldValues: NoteFieldValue[]; } /** * Card data prepared for study, including all necessary template rendering info. - * For note-based cards, includes templates and field values as a name-value map. - * For legacy cards, note and templates are null. */ export interface CardForStudy extends Card { - /** Note type templates for rendering (null for legacy cards) */ + /** Note type templates for rendering */ noteType: { frontTemplate: string; backTemplate: string; - } | null; - /** Field values as a name-value map for template rendering (empty for legacy cards) */ + }; + /** Field values as a name-value map for template rendering */ fieldValuesMap: Record<string, string>; } diff --git a/src/server/routes/cards.test.ts b/src/server/routes/cards.test.ts index 780ea44..66ba601 100644 --- a/src/server/routes/cards.test.ts +++ b/src/server/routes/cards.test.ts @@ -74,8 +74,8 @@ function createMockCard(overrides: Partial<Card> = {}): Card { return { id: "card-uuid-123", deckId: "deck-uuid-123", - noteId: null, - isReversed: null, + noteId: "note-uuid-123", + isReversed: false, front: "Question", back: "Answer", state: CardState.New, @@ -110,7 +110,7 @@ function createMockCardWithNoteData( ): CardWithNoteData { return { ...createMockCard(overrides), - note: overrides.note ?? null, + note: overrides.note ?? createMockNote(), fieldValues: overrides.fieldValues ?? [], }; } @@ -408,7 +408,6 @@ describe("GET /api/decks/:deckId/cards/:cardId", () => { const mockCardWithNote = createMockCardWithNoteData({ id: CARD_ID, deckId: DECK_ID, - note: null, fieldValues: [], }); vi.mocked(mockDeckRepo.findById).mockResolvedValue( diff --git a/src/server/routes/study.test.ts b/src/server/routes/study.test.ts index 41abecd..e2fb457 100644 --- a/src/server/routes/study.test.ts +++ b/src/server/routes/study.test.ts @@ -80,8 +80,8 @@ function createMockCard(overrides: Partial<Card> = {}): Card { return { id: "card-uuid-123", deckId: "deck-uuid-123", - noteId: null, - isReversed: null, + noteId: "note-uuid-123", + isReversed: false, front: "Question", back: "Answer", state: CardState.New, @@ -122,7 +122,10 @@ function createMockCardForStudy( ): CardForStudy { return { ...createMockCard(overrides), - noteType: overrides.noteType ?? null, + noteType: overrides.noteType ?? { + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + }, fieldValuesMap: overrides.fieldValuesMap ?? {}, }; } @@ -187,20 +190,18 @@ describe("GET /api/decks/:deckId/study", () => { ); }); - it("returns due cards (legacy cards without note)", async () => { + it("returns due cards", async () => { const mockCards = [ createMockCardForStudy({ id: "card-1", front: "Q1", back: "A1", - noteType: null, fieldValuesMap: {}, }), createMockCardForStudy({ id: "card-2", front: "Q2", back: "A2", - noteType: null, fieldValuesMap: {}, }), ]; @@ -217,7 +218,7 @@ describe("GET /api/decks/:deckId/study", () => { expect(res.status).toBe(200); const body = (await res.json()) as StudyResponse; expect(body.cards).toHaveLength(2); - expect(body.cards?.[0]?.noteType).toBeNull(); + expect(body.cards?.[0]?.noteType).toBeDefined(); }); it("returns due cards with note type and field values when available", async () => { diff --git a/src/server/routes/sync.test.ts b/src/server/routes/sync.test.ts index 1107acd..f340af7 100644 --- a/src/server/routes/sync.test.ts +++ b/src/server/routes/sync.test.ts @@ -186,8 +186,8 @@ describe("POST /api/sync/push", () => { const cardData = { id: "550e8400-e29b-41d4-a716-446655440001", deckId: "550e8400-e29b-41d4-a716-446655440000", - noteId: null, - isReversed: null, + noteId: "550e8400-e29b-41d4-a716-446655440020", + isReversed: false, front: "Question", back: "Answer", state: 0, @@ -435,8 +435,8 @@ describe("POST /api/sync/push", () => { const cardData = { id: "550e8400-e29b-41d4-a716-446655440005", deckId: "550e8400-e29b-41d4-a716-446655440004", - noteId: null, - isReversed: null, + noteId: "550e8400-e29b-41d4-a716-446655440020", + isReversed: false, front: "Q", back: "A", state: 0, @@ -823,8 +823,8 @@ describe("GET /api/sync/pull", () => { const mockCard: Card = { id: "550e8400-e29b-41d4-a716-446655440001", deckId: "550e8400-e29b-41d4-a716-446655440000", - noteId: null, - isReversed: null, + noteId: "550e8400-e29b-41d4-a716-446655440020", + isReversed: false, front: "Question", back: "Answer", state: 2, @@ -928,8 +928,8 @@ describe("GET /api/sync/pull", () => { const mockCard: Card = { id: "550e8400-e29b-41d4-a716-446655440001", deckId: "550e8400-e29b-41d4-a716-446655440000", - noteId: null, - isReversed: null, + noteId: "550e8400-e29b-41d4-a716-446655440020", + isReversed: false, front: "Q", back: "A", state: 0, diff --git a/src/server/routes/sync.ts b/src/server/routes/sync.ts index f05a7ba..fca099b 100644 --- a/src/server/routes/sync.ts +++ b/src/server/routes/sync.ts @@ -26,8 +26,8 @@ const syncDeckSchema = z.object({ const syncCardSchema = z.object({ id: z.uuid(), deckId: z.uuid(), - noteId: z.uuid().nullable(), - isReversed: z.boolean().nullable(), + noteId: z.uuid(), + isReversed: z.boolean(), front: z.string().min(1), back: z.string().min(1), state: z.number().int().min(0).max(3), |
