aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--docs/dev/roadmap.md2
-rw-r--r--src/client/db/repositories.test.ts697
-rw-r--r--src/client/db/repositories.ts439
3 files changed, 1136 insertions, 2 deletions
diff --git a/docs/dev/roadmap.md b/docs/dev/roadmap.md
index f7d7728..efbf900 100644
--- a/docs/dev/roadmap.md
+++ b/docs/dev/roadmap.md
@@ -207,7 +207,7 @@ Create these as default note types for each user:
- [x] Add `LocalNoteFieldValue` interface and table
- [x] Modify `LocalCard` interface: add `noteId`, `isReversed`
- [x] Update Dexie schema version and upgrade handler
-- [ ] Create client repositories for new entities
+- [x] Create client repositories for new entities
**Files to modify:**
- `src/client/db/index.ts`
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 });
+ },
+};