diff options
| author | nsfisis <nsfisis@gmail.com> | 2025-12-31 01:38:36 +0900 |
|---|---|---|
| committer | nsfisis <nsfisis@gmail.com> | 2025-12-31 01:38:36 +0900 |
| commit | 78609e0b390e9a485c8935c17db6e0093660ebef (patch) | |
| tree | 152819b21302074cb39cddb174652437bbb8adc7 /src | |
| parent | c1d24e24449808e4235fa586fbeb5760a36bc6bb (diff) | |
| download | kioku-78609e0b390e9a485c8935c17db6e0093660ebef.tar.gz kioku-78609e0b390e9a485c8935c17db6e0093660ebef.tar.zst kioku-78609e0b390e9a485c8935c17db6e0093660ebef.zip | |
feat(client): add IndexedDB repositories for note-related entities
Implement client-side repositories for NoteType, NoteFieldType, Note,
and NoteFieldValue with full CRUD operations and sync support. Includes
cascade soft-delete for notes (deletes related cards) and comprehensive
tests for all new repositories.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/db/repositories.test.ts | 697 | ||||
| -rw-r--r-- | src/client/db/repositories.ts | 439 |
2 files changed, 1135 insertions, 1 deletions
diff --git a/src/client/db/repositories.test.ts b/src/client/db/repositories.test.ts index 0121541..2fca210 100644 --- a/src/client/db/repositories.test.ts +++ b/src/client/db/repositories.test.ts @@ -7,6 +7,10 @@ import { CardState, db, Rating } from "./index"; import { localCardRepository, localDeckRepository, + localNoteFieldTypeRepository, + localNoteFieldValueRepository, + localNoteRepository, + localNoteTypeRepository, localReviewLogRepository, } from "./repositories"; @@ -133,7 +137,7 @@ describe("localDeckRepository", () => { expect(updated?.name).toBe("Updated Name"); expect(updated?.description).toBe("New description"); expect(updated?._synced).toBe(false); - expect(updated?.updatedAt.getTime()).toBeGreaterThan( + expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual( deck.updatedAt.getTime(), ); }); @@ -579,3 +583,694 @@ describe("localReviewLogRepository", () => { }); }); }); + +describe("localNoteTypeRepository", () => { + beforeEach(async () => { + await db.noteTypes.clear(); + await db.noteFieldTypes.clear(); + await db.notes.clear(); + await db.noteFieldValues.clear(); + }); + + afterEach(async () => { + await db.noteTypes.clear(); + await db.noteFieldTypes.clear(); + await db.notes.clear(); + await db.noteFieldValues.clear(); + }); + + describe("create", () => { + it("should create a note type with generated id and timestamps", async () => { + const noteType = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + + expect(noteType.id).toBeDefined(); + expect(noteType.userId).toBe("user-1"); + expect(noteType.name).toBe("Basic"); + expect(noteType.frontTemplate).toBe("{{Front}}"); + expect(noteType.backTemplate).toBe("{{Back}}"); + expect(noteType.isReversible).toBe(false); + expect(noteType.createdAt).toBeInstanceOf(Date); + expect(noteType.updatedAt).toBeInstanceOf(Date); + expect(noteType.deletedAt).toBeNull(); + expect(noteType.syncVersion).toBe(0); + expect(noteType._synced).toBe(false); + }); + + it("should persist the note type to the database", async () => { + const created = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + + const found = await db.noteTypes.get(created.id); + expect(found).toEqual(created); + }); + }); + + describe("findById", () => { + it("should return the note type if found", async () => { + const created = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + + const found = await localNoteTypeRepository.findById(created.id); + expect(found).toEqual(created); + }); + + it("should return undefined if not found", async () => { + const found = await localNoteTypeRepository.findById("non-existent"); + expect(found).toBeUndefined(); + }); + }); + + describe("findByUserId", () => { + it("should return all note types for a user", async () => { + await localNoteTypeRepository.create({ + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + await localNoteTypeRepository.create({ + userId: "user-1", + name: "Basic (reversed)", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: true, + }); + await localNoteTypeRepository.create({ + userId: "user-2", + name: "Other User Type", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + + const noteTypes = await localNoteTypeRepository.findByUserId("user-1"); + expect(noteTypes).toHaveLength(2); + expect(noteTypes.map((nt) => nt.name).sort()).toEqual([ + "Basic", + "Basic (reversed)", + ]); + }); + + it("should exclude soft-deleted note types", async () => { + const noteType = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Deleted Type", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + await localNoteTypeRepository.delete(noteType.id); + + const noteTypes = await localNoteTypeRepository.findByUserId("user-1"); + expect(noteTypes).toHaveLength(0); + }); + }); + + describe("update", () => { + it("should update note type fields", async () => { + const noteType = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Original Name", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + + const updated = await localNoteTypeRepository.update(noteType.id, { + name: "Updated Name", + frontTemplate: "Q: {{Front}}", + isReversible: true, + }); + + expect(updated?.name).toBe("Updated Name"); + expect(updated?.frontTemplate).toBe("Q: {{Front}}"); + expect(updated?.isReversible).toBe(true); + expect(updated?._synced).toBe(false); + expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual( + noteType.updatedAt.getTime(), + ); + }); + + it("should return undefined for non-existent note type", async () => { + const updated = await localNoteTypeRepository.update("non-existent", { + name: "New Name", + }); + expect(updated).toBeUndefined(); + }); + }); + + describe("delete", () => { + it("should soft delete a note type", async () => { + const noteType = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Test Type", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + + const result = await localNoteTypeRepository.delete(noteType.id); + expect(result).toBe(true); + + const found = await localNoteTypeRepository.findById(noteType.id); + expect(found?.deletedAt).not.toBeNull(); + expect(found?._synced).toBe(false); + }); + + it("should return false for non-existent note type", async () => { + const result = await localNoteTypeRepository.delete("non-existent"); + expect(result).toBe(false); + }); + }); + + describe("findUnsynced", () => { + it("should return unsynced note types", async () => { + const noteType1 = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Unsynced", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + const noteType2 = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Synced", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + await localNoteTypeRepository.markSynced(noteType2.id, 1); + + const unsynced = await localNoteTypeRepository.findUnsynced(); + expect(unsynced).toHaveLength(1); + expect(unsynced[0]?.id).toBe(noteType1.id); + }); + }); + + describe("markSynced", () => { + it("should mark a note type as synced with version", async () => { + const noteType = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Test", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + + await localNoteTypeRepository.markSynced(noteType.id, 5); + + const found = await localNoteTypeRepository.findById(noteType.id); + expect(found?._synced).toBe(true); + expect(found?.syncVersion).toBe(5); + }); + }); +}); + +describe("localNoteFieldTypeRepository", () => { + let noteTypeId: string; + + beforeEach(async () => { + await db.noteTypes.clear(); + await db.noteFieldTypes.clear(); + await db.notes.clear(); + await db.noteFieldValues.clear(); + + const noteType = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + noteTypeId = noteType.id; + }); + + afterEach(async () => { + await db.noteTypes.clear(); + await db.noteFieldTypes.clear(); + await db.notes.clear(); + await db.noteFieldValues.clear(); + }); + + describe("create", () => { + it("should create a field type with generated id and timestamps", async () => { + const fieldType = await localNoteFieldTypeRepository.create({ + noteTypeId, + name: "Front", + order: 0, + }); + + expect(fieldType.id).toBeDefined(); + expect(fieldType.noteTypeId).toBe(noteTypeId); + expect(fieldType.name).toBe("Front"); + expect(fieldType.order).toBe(0); + expect(fieldType.fieldType).toBe("text"); + expect(fieldType.createdAt).toBeInstanceOf(Date); + expect(fieldType.updatedAt).toBeInstanceOf(Date); + expect(fieldType.deletedAt).toBeNull(); + expect(fieldType.syncVersion).toBe(0); + expect(fieldType._synced).toBe(false); + }); + }); + + describe("findByNoteTypeId", () => { + it("should return all field types for a note type sorted by order", async () => { + await localNoteFieldTypeRepository.create({ + noteTypeId, + name: "Back", + order: 1, + }); + await localNoteFieldTypeRepository.create({ + noteTypeId, + name: "Front", + order: 0, + }); + + const fieldTypes = + await localNoteFieldTypeRepository.findByNoteTypeId(noteTypeId); + expect(fieldTypes).toHaveLength(2); + expect(fieldTypes[0]?.name).toBe("Front"); + expect(fieldTypes[1]?.name).toBe("Back"); + }); + + it("should exclude soft-deleted field types", async () => { + const fieldType = await localNoteFieldTypeRepository.create({ + noteTypeId, + name: "Deleted", + order: 0, + }); + await localNoteFieldTypeRepository.delete(fieldType.id); + + const fieldTypes = + await localNoteFieldTypeRepository.findByNoteTypeId(noteTypeId); + expect(fieldTypes).toHaveLength(0); + }); + }); + + describe("update", () => { + it("should update field type fields", async () => { + const fieldType = await localNoteFieldTypeRepository.create({ + noteTypeId, + name: "Original", + order: 0, + }); + + const updated = await localNoteFieldTypeRepository.update(fieldType.id, { + name: "Updated", + order: 1, + }); + + expect(updated?.name).toBe("Updated"); + expect(updated?.order).toBe(1); + expect(updated?._synced).toBe(false); + }); + }); + + describe("findUnsynced", () => { + it("should return unsynced field types", async () => { + const fieldType1 = await localNoteFieldTypeRepository.create({ + noteTypeId, + name: "Unsynced", + order: 0, + }); + const fieldType2 = await localNoteFieldTypeRepository.create({ + noteTypeId, + name: "Synced", + order: 1, + }); + await localNoteFieldTypeRepository.markSynced(fieldType2.id, 1); + + const unsynced = await localNoteFieldTypeRepository.findUnsynced(); + expect(unsynced).toHaveLength(1); + expect(unsynced[0]?.id).toBe(fieldType1.id); + }); + }); +}); + +describe("localNoteRepository", () => { + let deckId: string; + let noteTypeId: string; + + beforeEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.noteTypes.clear(); + await db.noteFieldTypes.clear(); + await db.notes.clear(); + await db.noteFieldValues.clear(); + + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + deckId = deck.id; + + const noteType = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + noteTypeId = noteType.id; + }); + + afterEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.noteTypes.clear(); + await db.noteFieldTypes.clear(); + await db.notes.clear(); + await db.noteFieldValues.clear(); + }); + + describe("create", () => { + it("should create a note with generated id and timestamps", async () => { + const note = await localNoteRepository.create({ + deckId, + noteTypeId, + }); + + expect(note.id).toBeDefined(); + expect(note.deckId).toBe(deckId); + expect(note.noteTypeId).toBe(noteTypeId); + expect(note.createdAt).toBeInstanceOf(Date); + expect(note.updatedAt).toBeInstanceOf(Date); + expect(note.deletedAt).toBeNull(); + expect(note.syncVersion).toBe(0); + expect(note._synced).toBe(false); + }); + }); + + describe("findByDeckId", () => { + it("should return all notes for a deck", async () => { + await localNoteRepository.create({ deckId, noteTypeId }); + await localNoteRepository.create({ deckId, noteTypeId }); + + const notes = await localNoteRepository.findByDeckId(deckId); + expect(notes).toHaveLength(2); + }); + + it("should exclude soft-deleted notes", async () => { + const note = await localNoteRepository.create({ deckId, noteTypeId }); + await localNoteRepository.delete(note.id); + + const notes = await localNoteRepository.findByDeckId(deckId); + expect(notes).toHaveLength(0); + }); + }); + + describe("findByNoteTypeId", () => { + it("should return all notes for a note type", async () => { + await localNoteRepository.create({ deckId, noteTypeId }); + + const notes = await localNoteRepository.findByNoteTypeId(noteTypeId); + expect(notes).toHaveLength(1); + }); + }); + + describe("update", () => { + it("should update note metadata", async () => { + const note = await localNoteRepository.create({ deckId, noteTypeId }); + + const updated = await localNoteRepository.update(note.id); + + expect(updated?._synced).toBe(false); + expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual( + note.updatedAt.getTime(), + ); + }); + + it("should return undefined for non-existent note", async () => { + const updated = await localNoteRepository.update("non-existent"); + expect(updated).toBeUndefined(); + }); + }); + + describe("delete", () => { + it("should soft delete a note", async () => { + const note = await localNoteRepository.create({ deckId, noteTypeId }); + + const result = await localNoteRepository.delete(note.id); + expect(result).toBe(true); + + const found = await localNoteRepository.findById(note.id); + expect(found?.deletedAt).not.toBeNull(); + }); + + it("should cascade soft delete to related cards", async () => { + const note = await localNoteRepository.create({ deckId, noteTypeId }); + + // Create cards associated with this note + const card1 = await localCardRepository.create({ + deckId, + front: "Q1", + back: "A1", + noteId: note.id, + isReversed: false, + }); + const card2 = await localCardRepository.create({ + deckId, + front: "Q2", + back: "A2", + noteId: note.id, + isReversed: true, + }); + + await localNoteRepository.delete(note.id); + + const foundCard1 = await localCardRepository.findById(card1.id); + const foundCard2 = await localCardRepository.findById(card2.id); + expect(foundCard1?.deletedAt).not.toBeNull(); + expect(foundCard2?.deletedAt).not.toBeNull(); + }); + + it("should return false for non-existent note", async () => { + const result = await localNoteRepository.delete("non-existent"); + expect(result).toBe(false); + }); + }); + + describe("findUnsynced", () => { + it("should return unsynced notes", async () => { + const note1 = await localNoteRepository.create({ deckId, noteTypeId }); + const note2 = await localNoteRepository.create({ deckId, noteTypeId }); + await localNoteRepository.markSynced(note2.id, 1); + + const unsynced = await localNoteRepository.findUnsynced(); + expect(unsynced).toHaveLength(1); + expect(unsynced[0]?.id).toBe(note1.id); + }); + }); +}); + +describe("localNoteFieldValueRepository", () => { + let noteId: string; + let noteFieldTypeId: string; + let noteTypeId: string; + + beforeEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.noteTypes.clear(); + await db.noteFieldTypes.clear(); + await db.notes.clear(); + await db.noteFieldValues.clear(); + + const deck = await localDeckRepository.create({ + userId: "user-1", + name: "Test Deck", + description: null, + newCardsPerDay: 20, + }); + + const noteType = await localNoteTypeRepository.create({ + userId: "user-1", + name: "Basic", + frontTemplate: "{{Front}}", + backTemplate: "{{Back}}", + isReversible: false, + }); + noteTypeId = noteType.id; + + const fieldType = await localNoteFieldTypeRepository.create({ + noteTypeId: noteType.id, + name: "Front", + order: 0, + }); + noteFieldTypeId = fieldType.id; + + const note = await localNoteRepository.create({ + deckId: deck.id, + noteTypeId: noteType.id, + }); + noteId = note.id; + }); + + afterEach(async () => { + await db.decks.clear(); + await db.cards.clear(); + await db.noteTypes.clear(); + await db.noteFieldTypes.clear(); + await db.notes.clear(); + await db.noteFieldValues.clear(); + }); + + describe("create", () => { + it("should create a field value with generated id and timestamps", async () => { + const fieldValue = await localNoteFieldValueRepository.create({ + noteId, + noteFieldTypeId, + value: "What is the capital of Japan?", + }); + + expect(fieldValue.id).toBeDefined(); + expect(fieldValue.noteId).toBe(noteId); + expect(fieldValue.noteFieldTypeId).toBe(noteFieldTypeId); + expect(fieldValue.value).toBe("What is the capital of Japan?"); + expect(fieldValue.createdAt).toBeInstanceOf(Date); + expect(fieldValue.updatedAt).toBeInstanceOf(Date); + expect(fieldValue.syncVersion).toBe(0); + expect(fieldValue._synced).toBe(false); + }); + }); + + describe("findByNoteId", () => { + it("should return all field values for a note", async () => { + await localNoteFieldValueRepository.create({ + noteId, + noteFieldTypeId, + value: "Front value", + }); + + const backFieldType = await localNoteFieldTypeRepository.create({ + noteTypeId, + name: "Back", + order: 1, + }); + await localNoteFieldValueRepository.create({ + noteId, + noteFieldTypeId: backFieldType.id, + value: "Back value", + }); + + const fieldValues = + await localNoteFieldValueRepository.findByNoteId(noteId); + expect(fieldValues).toHaveLength(2); + }); + }); + + describe("findByNoteIdAndFieldTypeId", () => { + it("should return the field value for a specific note and field type", async () => { + await localNoteFieldValueRepository.create({ + noteId, + noteFieldTypeId, + value: "Test value", + }); + + const found = + await localNoteFieldValueRepository.findByNoteIdAndFieldTypeId( + noteId, + noteFieldTypeId, + ); + expect(found?.value).toBe("Test value"); + }); + + it("should return undefined if not found", async () => { + const found = + await localNoteFieldValueRepository.findByNoteIdAndFieldTypeId( + noteId, + "non-existent", + ); + expect(found).toBeUndefined(); + }); + }); + + describe("update", () => { + it("should update field value", async () => { + const fieldValue = await localNoteFieldValueRepository.create({ + noteId, + noteFieldTypeId, + value: "Original", + }); + + const updated = await localNoteFieldValueRepository.update( + fieldValue.id, + { + value: "Updated", + }, + ); + + expect(updated?.value).toBe("Updated"); + expect(updated?._synced).toBe(false); + expect(updated?.updatedAt.getTime()).toBeGreaterThanOrEqual( + fieldValue.updatedAt.getTime(), + ); + }); + + it("should return undefined for non-existent field value", async () => { + const updated = await localNoteFieldValueRepository.update( + "non-existent", + { + value: "Updated", + }, + ); + expect(updated).toBeUndefined(); + }); + }); + + describe("findUnsynced", () => { + it("should return unsynced field values", async () => { + const fieldValue1 = await localNoteFieldValueRepository.create({ + noteId, + noteFieldTypeId, + value: "Unsynced", + }); + const fieldValue2 = await localNoteFieldValueRepository.create({ + noteId, + noteFieldTypeId: noteFieldTypeId, + value: "Synced", + }); + await localNoteFieldValueRepository.markSynced(fieldValue2.id, 1); + + const unsynced = await localNoteFieldValueRepository.findUnsynced(); + expect(unsynced).toHaveLength(1); + expect(unsynced[0]?.id).toBe(fieldValue1.id); + }); + }); + + describe("markSynced", () => { + it("should mark a field value as synced with version", async () => { + const fieldValue = await localNoteFieldValueRepository.create({ + noteId, + noteFieldTypeId, + value: "Test", + }); + + await localNoteFieldValueRepository.markSynced(fieldValue.id, 5); + + const found = await localNoteFieldValueRepository.findById(fieldValue.id); + expect(found?._synced).toBe(true); + expect(found?.syncVersion).toBe(5); + }); + }); +}); diff --git a/src/client/db/repositories.ts b/src/client/db/repositories.ts index a2f0b41..104f026 100644 --- a/src/client/db/repositories.ts +++ b/src/client/db/repositories.ts @@ -2,8 +2,13 @@ import { v4 as uuidv4 } from "uuid"; import { CardState, db, + FieldType, type LocalCard, type LocalDeck, + type LocalNote, + type LocalNoteFieldType, + type LocalNoteFieldValue, + type LocalNoteType, type LocalReviewLog, } from "./index"; @@ -380,3 +385,437 @@ export const localReviewLogRepository = { .toArray(); }, }; + +/** + * Local note type repository for IndexedDB operations + */ +export const localNoteTypeRepository = { + /** + * Get all note types for a user (excluding soft-deleted) + */ + async findByUserId(userId: string): Promise<LocalNoteType[]> { + return db.noteTypes + .where("userId") + .equals(userId) + .filter((noteType) => noteType.deletedAt === null) + .toArray(); + }, + + /** + * Get a note type by ID + */ + async findById(id: string): Promise<LocalNoteType | undefined> { + return db.noteTypes.get(id); + }, + + /** + * Create a new note type + */ + async create( + data: Omit< + LocalNoteType, + "id" | "createdAt" | "updatedAt" | "deletedAt" | "syncVersion" | "_synced" + >, + ): Promise<LocalNoteType> { + const now = new Date(); + const noteType: LocalNoteType = { + id: uuidv4(), + ...data, + createdAt: now, + updatedAt: now, + deletedAt: null, + syncVersion: 0, + _synced: false, + }; + await db.noteTypes.add(noteType); + return noteType; + }, + + /** + * Update a note type + */ + async update( + id: string, + data: Partial< + Pick< + LocalNoteType, + "name" | "frontTemplate" | "backTemplate" | "isReversible" + > + >, + ): Promise<LocalNoteType | undefined> { + const noteType = await db.noteTypes.get(id); + if (!noteType) return undefined; + + const updatedNoteType: LocalNoteType = { + ...noteType, + ...data, + updatedAt: new Date(), + _synced: false, + }; + await db.noteTypes.put(updatedNoteType); + return updatedNoteType; + }, + + /** + * Soft delete a note type + */ + async delete(id: string): Promise<boolean> { + const noteType = await db.noteTypes.get(id); + if (!noteType) return false; + + await db.noteTypes.update(id, { + deletedAt: new Date(), + updatedAt: new Date(), + _synced: false, + }); + return true; + }, + + /** + * Get all unsynced note types + */ + async findUnsynced(): Promise<LocalNoteType[]> { + return db.noteTypes.filter((noteType) => !noteType._synced).toArray(); + }, + + /** + * Mark a note type as synced + */ + async markSynced(id: string, syncVersion: number): Promise<void> { + await db.noteTypes.update(id, { _synced: true, syncVersion }); + }, + + /** + * Upsert a note type from server (for sync pull) + */ + async upsertFromServer(noteType: LocalNoteType): Promise<void> { + await db.noteTypes.put({ ...noteType, _synced: true }); + }, +}; + +/** + * Local note field type repository for IndexedDB operations + */ +export const localNoteFieldTypeRepository = { + /** + * Get all field types for a note type (excluding soft-deleted) + */ + async findByNoteTypeId(noteTypeId: string): Promise<LocalNoteFieldType[]> { + const fields = await db.noteFieldTypes + .where("noteTypeId") + .equals(noteTypeId) + .filter((field) => field.deletedAt === null) + .toArray(); + // Sort by order + return fields.sort((a, b) => a.order - b.order); + }, + + /** + * Get a field type by ID + */ + async findById(id: string): Promise<LocalNoteFieldType | undefined> { + return db.noteFieldTypes.get(id); + }, + + /** + * Create a new field type + */ + async create( + data: Omit< + LocalNoteFieldType, + | "id" + | "fieldType" + | "createdAt" + | "updatedAt" + | "deletedAt" + | "syncVersion" + | "_synced" + >, + ): Promise<LocalNoteFieldType> { + const now = new Date(); + const fieldType: LocalNoteFieldType = { + id: uuidv4(), + ...data, + fieldType: FieldType.Text, + createdAt: now, + updatedAt: now, + deletedAt: null, + syncVersion: 0, + _synced: false, + }; + await db.noteFieldTypes.add(fieldType); + return fieldType; + }, + + /** + * Update a field type + */ + async update( + id: string, + data: Partial<Pick<LocalNoteFieldType, "name" | "order">>, + ): Promise<LocalNoteFieldType | undefined> { + const fieldType = await db.noteFieldTypes.get(id); + if (!fieldType) return undefined; + + const updatedFieldType: LocalNoteFieldType = { + ...fieldType, + ...data, + updatedAt: new Date(), + _synced: false, + }; + await db.noteFieldTypes.put(updatedFieldType); + return updatedFieldType; + }, + + /** + * Soft delete a field type + */ + async delete(id: string): Promise<boolean> { + const fieldType = await db.noteFieldTypes.get(id); + if (!fieldType) return false; + + await db.noteFieldTypes.update(id, { + deletedAt: new Date(), + updatedAt: new Date(), + _synced: false, + }); + return true; + }, + + /** + * Get all unsynced field types + */ + async findUnsynced(): Promise<LocalNoteFieldType[]> { + return db.noteFieldTypes.filter((field) => !field._synced).toArray(); + }, + + /** + * Mark a field type as synced + */ + async markSynced(id: string, syncVersion: number): Promise<void> { + await db.noteFieldTypes.update(id, { _synced: true, syncVersion }); + }, + + /** + * Upsert a field type from server (for sync pull) + */ + async upsertFromServer(fieldType: LocalNoteFieldType): Promise<void> { + await db.noteFieldTypes.put({ ...fieldType, _synced: true }); + }, +}; + +/** + * Local note repository for IndexedDB operations + */ +export const localNoteRepository = { + /** + * Get all notes for a deck (excluding soft-deleted) + */ + async findByDeckId(deckId: string): Promise<LocalNote[]> { + return db.notes + .where("deckId") + .equals(deckId) + .filter((note) => note.deletedAt === null) + .toArray(); + }, + + /** + * Get all notes for a note type (excluding soft-deleted) + */ + async findByNoteTypeId(noteTypeId: string): Promise<LocalNote[]> { + return db.notes + .where("noteTypeId") + .equals(noteTypeId) + .filter((note) => note.deletedAt === null) + .toArray(); + }, + + /** + * Get a note by ID + */ + async findById(id: string): Promise<LocalNote | undefined> { + return db.notes.get(id); + }, + + /** + * Create a new note + */ + async create( + data: Omit< + LocalNote, + "id" | "createdAt" | "updatedAt" | "deletedAt" | "syncVersion" | "_synced" + >, + ): Promise<LocalNote> { + const now = new Date(); + const note: LocalNote = { + id: uuidv4(), + ...data, + createdAt: now, + updatedAt: now, + deletedAt: null, + syncVersion: 0, + _synced: false, + }; + await db.notes.add(note); + return note; + }, + + /** + * Update a note's metadata (triggers updatedAt change) + */ + async update(id: string): Promise<LocalNote | undefined> { + const note = await db.notes.get(id); + if (!note) return undefined; + + const updatedNote: LocalNote = { + ...note, + updatedAt: new Date(), + _synced: false, + }; + await db.notes.put(updatedNote); + return updatedNote; + }, + + /** + * Soft delete a note and its related cards + */ + async delete(id: string): Promise<boolean> { + const note = await db.notes.get(id); + if (!note) return false; + + const now = new Date(); + + // Cascade soft-delete to all cards associated with this note + await db.cards.where("noteId").equals(id).modify({ + deletedAt: now, + updatedAt: now, + _synced: false, + }); + + // Soft delete the note + await db.notes.update(id, { + deletedAt: now, + updatedAt: now, + _synced: false, + }); + + return true; + }, + + /** + * Get all unsynced notes + */ + async findUnsynced(): Promise<LocalNote[]> { + return db.notes.filter((note) => !note._synced).toArray(); + }, + + /** + * Mark a note as synced + */ + async markSynced(id: string, syncVersion: number): Promise<void> { + await db.notes.update(id, { _synced: true, syncVersion }); + }, + + /** + * Upsert a note from server (for sync pull) + */ + async upsertFromServer(note: LocalNote): Promise<void> { + await db.notes.put({ ...note, _synced: true }); + }, +}; + +/** + * Local note field value repository for IndexedDB operations + */ +export const localNoteFieldValueRepository = { + /** + * Get all field values for a note + */ + async findByNoteId(noteId: string): Promise<LocalNoteFieldValue[]> { + return db.noteFieldValues.where("noteId").equals(noteId).toArray(); + }, + + /** + * Get a field value by ID + */ + async findById(id: string): Promise<LocalNoteFieldValue | undefined> { + return db.noteFieldValues.get(id); + }, + + /** + * Get a field value by note ID and field type ID + */ + async findByNoteIdAndFieldTypeId( + noteId: string, + noteFieldTypeId: string, + ): Promise<LocalNoteFieldValue | undefined> { + return db.noteFieldValues + .where("noteId") + .equals(noteId) + .filter((value) => value.noteFieldTypeId === noteFieldTypeId) + .first(); + }, + + /** + * Create a new field value + */ + async create( + data: Omit< + LocalNoteFieldValue, + "id" | "createdAt" | "updatedAt" | "syncVersion" | "_synced" + >, + ): Promise<LocalNoteFieldValue> { + const now = new Date(); + const fieldValue: LocalNoteFieldValue = { + id: uuidv4(), + ...data, + createdAt: now, + updatedAt: now, + syncVersion: 0, + _synced: false, + }; + await db.noteFieldValues.add(fieldValue); + return fieldValue; + }, + + /** + * Update a field value + */ + async update( + id: string, + data: Partial<Pick<LocalNoteFieldValue, "value">>, + ): Promise<LocalNoteFieldValue | undefined> { + const fieldValue = await db.noteFieldValues.get(id); + if (!fieldValue) return undefined; + + const updatedFieldValue: LocalNoteFieldValue = { + ...fieldValue, + ...data, + updatedAt: new Date(), + _synced: false, + }; + await db.noteFieldValues.put(updatedFieldValue); + return updatedFieldValue; + }, + + /** + * Get all unsynced field values + */ + async findUnsynced(): Promise<LocalNoteFieldValue[]> { + return db.noteFieldValues.filter((value) => !value._synced).toArray(); + }, + + /** + * Mark a field value as synced + */ + async markSynced(id: string, syncVersion: number): Promise<void> { + await db.noteFieldValues.update(id, { _synced: true, syncVersion }); + }, + + /** + * Upsert a field value from server (for sync pull) + */ + async upsertFromServer(fieldValue: LocalNoteFieldValue): Promise<void> { + await db.noteFieldValues.put({ ...fieldValue, _synced: true }); + }, +}; |
