aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authornsfisis <nsfisis@gmail.com>2025-12-31 02:15:17 +0900
committernsfisis <nsfisis@gmail.com>2025-12-31 02:15:17 +0900
commited93dd099f43dd6746276a72953485de91b49c8c (patch)
treedb737032e32508b7de24d94696a13e4bfebe8978
parent78609e0b390e9a485c8935c17db6e0093660ebef (diff)
downloadkioku-ed93dd099f43dd6746276a72953485de91b49c8c.tar.gz
kioku-ed93dd099f43dd6746276a72953485de91b49c8c.tar.zst
kioku-ed93dd099f43dd6746276a72953485de91b49c8c.zip
feat(sync): add sync support for note-related entities
Extend the sync system to handle NoteType, NoteFieldType, Note, and NoteFieldValue entities. This includes: - Server sync repository and routes for push/pull of new entities - Client sync queue, push, pull, and conflict resolution for notes - Update Card sync to include noteId and isReversed fields - Add comprehensive tests for all sync functionality 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
-rw-r--r--src/client/stores/sync.test.tsx26
-rw-r--r--src/client/stores/sync.tsx54
-rw-r--r--src/client/sync/conflict.test.ts99
-rw-r--r--src/client/sync/conflict.ts337
-rw-r--r--src/client/sync/manager.test.ts56
-rw-r--r--src/client/sync/pull.test.ts48
-rw-r--r--src/client/sync/pull.ts153
-rw-r--r--src/client/sync/push.test.ts80
-rw-r--r--src/client/sync/push.ts157
-rw-r--r--src/client/sync/queue.test.ts32
-rw-r--r--src/client/sync/queue.ts106
-rw-r--r--src/server/repositories/sync.ts535
-rw-r--r--src/server/routes/sync.test.ts370
-rw-r--r--src/server/routes/sync.ts46
14 files changed, 2027 insertions, 72 deletions
diff --git a/src/client/stores/sync.test.tsx b/src/client/stores/sync.test.tsx
index 9c4e5c2..fee79d7 100644
--- a/src/client/stores/sync.test.tsx
+++ b/src/client/stores/sync.test.tsx
@@ -38,7 +38,18 @@ describe("useSync", () => {
decks: [],
cards: [],
reviewLogs: [],
- conflicts: { decks: [], cards: [] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: {
+ decks: [],
+ cards: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ },
currentSyncVersion: 0,
}),
});
@@ -138,7 +149,18 @@ describe("useSync", () => {
decks: [],
cards: [],
reviewLogs: [],
- conflicts: { decks: [], cards: [] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: {
+ decks: [],
+ cards: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ },
currentSyncVersion: 1,
}),
});
diff --git a/src/client/stores/sync.tsx b/src/client/stores/sync.tsx
index 29c6c4f..aea5c16 100644
--- a/src/client/stores/sync.tsx
+++ b/src/client/stores/sync.tsx
@@ -22,6 +22,10 @@ import {
import type {
ServerCard,
ServerDeck,
+ ServerNote,
+ ServerNoteFieldType,
+ ServerNoteFieldValue,
+ ServerNoteType,
ServerReviewLog,
SyncPullResult,
} from "../sync/pull";
@@ -73,6 +77,33 @@ interface PullResponse {
reviewedAt: string;
}
>;
+ noteTypes: Array<
+ Omit<ServerNoteType, "createdAt" | "updatedAt" | "deletedAt"> & {
+ createdAt: string;
+ updatedAt: string;
+ deletedAt: string | null;
+ }
+ >;
+ noteFieldTypes: Array<
+ Omit<ServerNoteFieldType, "createdAt" | "updatedAt" | "deletedAt"> & {
+ createdAt: string;
+ updatedAt: string;
+ deletedAt: string | null;
+ }
+ >;
+ notes: Array<
+ Omit<ServerNote, "createdAt" | "updatedAt" | "deletedAt"> & {
+ createdAt: string;
+ updatedAt: string;
+ deletedAt: string | null;
+ }
+ >;
+ noteFieldValues: Array<
+ Omit<ServerNoteFieldValue, "createdAt" | "updatedAt"> & {
+ createdAt: string;
+ updatedAt: string;
+ }
+ >;
currentSyncVersion: number;
}
@@ -141,6 +172,29 @@ async function pullFromServer(
...r,
reviewedAt: new Date(r.reviewedAt),
})),
+ noteTypes: data.noteTypes.map((n) => ({
+ ...n,
+ createdAt: new Date(n.createdAt),
+ updatedAt: new Date(n.updatedAt),
+ deletedAt: n.deletedAt ? new Date(n.deletedAt) : null,
+ })),
+ noteFieldTypes: data.noteFieldTypes.map((f) => ({
+ ...f,
+ createdAt: new Date(f.createdAt),
+ updatedAt: new Date(f.updatedAt),
+ deletedAt: f.deletedAt ? new Date(f.deletedAt) : null,
+ })),
+ notes: data.notes.map((n) => ({
+ ...n,
+ createdAt: new Date(n.createdAt),
+ updatedAt: new Date(n.updatedAt),
+ deletedAt: n.deletedAt ? new Date(n.deletedAt) : null,
+ })),
+ noteFieldValues: data.noteFieldValues.map((v) => ({
+ ...v,
+ createdAt: new Date(v.createdAt),
+ updatedAt: new Date(v.updatedAt),
+ })),
currentSyncVersion: data.currentSyncVersion,
};
}
diff --git a/src/client/sync/conflict.test.ts b/src/client/sync/conflict.test.ts
index 211f410..6a10c4f 100644
--- a/src/client/sync/conflict.test.ts
+++ b/src/client/sync/conflict.test.ts
@@ -9,6 +9,29 @@ import { ConflictResolver } from "./conflict";
import type { SyncPullResult } from "./pull";
import type { SyncPushResult } from "./push";
+function createEmptyConflicts() {
+ return {
+ decks: [] as string[],
+ cards: [] as string[],
+ noteTypes: [] as string[],
+ noteFieldTypes: [] as string[],
+ notes: [] as string[],
+ noteFieldValues: [] as string[],
+ };
+}
+
+function createEmptyPullResult(
+ currentSyncVersion = 0,
+): Omit<SyncPullResult, "decks" | "cards" | "reviewLogs"> {
+ return {
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ currentSyncVersion,
+ };
+}
+
describe("ConflictResolver", () => {
beforeEach(async () => {
await db.decks.clear();
@@ -31,7 +54,11 @@ describe("ConflictResolver", () => {
decks: [{ id: "deck-1", syncVersion: 1 }],
cards: [],
reviewLogs: [],
- conflicts: { decks: [], cards: [] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: createEmptyConflicts(),
};
expect(resolver.hasConflicts(pushResult)).toBe(false);
@@ -43,7 +70,11 @@ describe("ConflictResolver", () => {
decks: [{ id: "deck-1", syncVersion: 1 }],
cards: [],
reviewLogs: [],
- conflicts: { decks: ["deck-1"], cards: [] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: { ...createEmptyConflicts(), decks: ["deck-1"] },
};
expect(resolver.hasConflicts(pushResult)).toBe(true);
@@ -55,7 +86,11 @@ describe("ConflictResolver", () => {
decks: [],
cards: [{ id: "card-1", syncVersion: 1 }],
reviewLogs: [],
- conflicts: { decks: [], cards: ["card-1"] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: { ...createEmptyConflicts(), cards: ["card-1"] },
};
expect(resolver.hasConflicts(pushResult)).toBe(true);
@@ -69,7 +104,11 @@ describe("ConflictResolver", () => {
decks: [],
cards: [],
reviewLogs: [],
- conflicts: { decks: ["deck-1", "deck-2"], cards: [] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: { ...createEmptyConflicts(), decks: ["deck-1", "deck-2"] },
};
expect(resolver.getConflictingDeckIds(pushResult)).toEqual([
@@ -86,7 +125,11 @@ describe("ConflictResolver", () => {
decks: [],
cards: [],
reviewLogs: [],
- conflicts: { decks: [], cards: ["card-1", "card-2"] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: { ...createEmptyConflicts(), cards: ["card-1", "card-2"] },
};
expect(resolver.getConflictingCardIds(pushResult)).toEqual([
@@ -329,7 +372,14 @@ describe("ConflictResolver", () => {
],
cards: [],
reviewLogs: [],
- conflicts: { decks: [deck1.id, deck2.id], cards: [] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: {
+ ...createEmptyConflicts(),
+ decks: [deck1.id, deck2.id],
+ },
};
const pullResult: SyncPullResult = {
@@ -359,7 +409,7 @@ describe("ConflictResolver", () => {
],
cards: [],
reviewLogs: [],
- currentSyncVersion: 6,
+ ...createEmptyPullResult(6),
};
const resolver = new ConflictResolver({ strategy: "server_wins" });
@@ -393,7 +443,11 @@ describe("ConflictResolver", () => {
decks: [],
cards: [{ id: card.id, syncVersion: 1 }],
reviewLogs: [],
- conflicts: { decks: [], cards: [card.id] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: { ...createEmptyConflicts(), cards: [card.id] },
};
const pullResult: SyncPullResult = {
@@ -420,7 +474,7 @@ describe("ConflictResolver", () => {
},
],
reviewLogs: [],
- currentSyncVersion: 3,
+ ...createEmptyPullResult(3),
};
const resolver = new ConflictResolver({ strategy: "server_wins" });
@@ -438,7 +492,14 @@ describe("ConflictResolver", () => {
decks: [],
cards: [],
reviewLogs: [],
- conflicts: { decks: ["non-existent-deck"], cards: [] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: {
+ ...createEmptyConflicts(),
+ decks: ["non-existent-deck"],
+ },
};
const pullResult: SyncPullResult = {
@@ -457,7 +518,7 @@ describe("ConflictResolver", () => {
],
cards: [],
reviewLogs: [],
- currentSyncVersion: 1,
+ ...createEmptyPullResult(1),
};
const resolver = new ConflictResolver({ strategy: "server_wins" });
@@ -483,14 +544,18 @@ describe("ConflictResolver", () => {
decks: [{ id: deck.id, syncVersion: 1 }],
cards: [],
reviewLogs: [],
- conflicts: { decks: [deck.id], cards: [] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: { ...createEmptyConflicts(), decks: [deck.id] },
};
const pullResult: SyncPullResult = {
decks: [], // Server doesn't have this deck
cards: [],
reviewLogs: [],
- currentSyncVersion: 0,
+ ...createEmptyPullResult(0),
};
const resolver = new ConflictResolver({ strategy: "server_wins" });
@@ -516,7 +581,11 @@ describe("ConflictResolver", () => {
decks: [{ id: deck.id, syncVersion: 1 }],
cards: [],
reviewLogs: [],
- conflicts: { decks: [deck.id], cards: [] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: { ...createEmptyConflicts(), decks: [deck.id] },
};
const pullResult: SyncPullResult = {
@@ -535,7 +604,7 @@ describe("ConflictResolver", () => {
],
cards: [],
reviewLogs: [],
- currentSyncVersion: 5,
+ ...createEmptyPullResult(5),
};
// Create resolver without explicit strategy
diff --git a/src/client/sync/conflict.ts b/src/client/sync/conflict.ts
index e4f1cbf..d9c3f55 100644
--- a/src/client/sync/conflict.ts
+++ b/src/client/sync/conflict.ts
@@ -1,6 +1,28 @@
-import type { LocalCard, LocalDeck } from "../db/index";
-import { localCardRepository, localDeckRepository } from "../db/repositories";
-import type { ServerCard, ServerDeck, SyncPullResult } from "./pull";
+import type {
+ LocalCard,
+ LocalDeck,
+ LocalNote,
+ LocalNoteFieldType,
+ LocalNoteFieldValue,
+ LocalNoteType,
+} from "../db/index";
+import {
+ localCardRepository,
+ localDeckRepository,
+ localNoteFieldTypeRepository,
+ localNoteFieldValueRepository,
+ localNoteRepository,
+ localNoteTypeRepository,
+} from "../db/repositories";
+import type {
+ ServerCard,
+ ServerDeck,
+ ServerNote,
+ ServerNoteFieldType,
+ ServerNoteFieldValue,
+ ServerNoteType,
+ SyncPullResult,
+} from "./pull";
import type { SyncPushResult } from "./push";
/**
@@ -17,6 +39,10 @@ export interface ConflictResolutionItem {
export interface ConflictResolutionResult {
decks: ConflictResolutionItem[];
cards: ConflictResolutionItem[];
+ noteTypes: ConflictResolutionItem[];
+ noteFieldTypes: ConflictResolutionItem[];
+ notes: ConflictResolutionItem[];
+ noteFieldValues: ConflictResolutionItem[];
}
/**
@@ -87,6 +113,79 @@ function serverCardToLocal(card: ServerCard): LocalCard {
}
/**
+ * Convert server note type to local format for storage
+ */
+function serverNoteTypeToLocal(noteType: ServerNoteType): LocalNoteType {
+ return {
+ id: noteType.id,
+ userId: noteType.userId,
+ name: noteType.name,
+ frontTemplate: noteType.frontTemplate,
+ backTemplate: noteType.backTemplate,
+ isReversible: noteType.isReversible,
+ createdAt: new Date(noteType.createdAt),
+ updatedAt: new Date(noteType.updatedAt),
+ deletedAt: noteType.deletedAt ? new Date(noteType.deletedAt) : null,
+ syncVersion: noteType.syncVersion,
+ _synced: true,
+ };
+}
+
+/**
+ * Convert server note field type to local format for storage
+ */
+function serverNoteFieldTypeToLocal(
+ fieldType: ServerNoteFieldType,
+): LocalNoteFieldType {
+ return {
+ id: fieldType.id,
+ noteTypeId: fieldType.noteTypeId,
+ name: fieldType.name,
+ order: fieldType.order,
+ fieldType: fieldType.fieldType as LocalNoteFieldType["fieldType"],
+ createdAt: new Date(fieldType.createdAt),
+ updatedAt: new Date(fieldType.updatedAt),
+ deletedAt: fieldType.deletedAt ? new Date(fieldType.deletedAt) : null,
+ syncVersion: fieldType.syncVersion,
+ _synced: true,
+ };
+}
+
+/**
+ * Convert server note to local format for storage
+ */
+function serverNoteToLocal(note: ServerNote): LocalNote {
+ return {
+ id: note.id,
+ deckId: note.deckId,
+ noteTypeId: note.noteTypeId,
+ createdAt: new Date(note.createdAt),
+ updatedAt: new Date(note.updatedAt),
+ deletedAt: note.deletedAt ? new Date(note.deletedAt) : null,
+ syncVersion: note.syncVersion,
+ _synced: true,
+ };
+}
+
+/**
+ * Convert server note field value to local format for storage
+ */
+function serverNoteFieldValueToLocal(
+ fieldValue: ServerNoteFieldValue,
+): LocalNoteFieldValue {
+ return {
+ id: fieldValue.id,
+ noteId: fieldValue.noteId,
+ noteFieldTypeId: fieldValue.noteFieldTypeId,
+ value: fieldValue.value,
+ createdAt: new Date(fieldValue.createdAt),
+ updatedAt: new Date(fieldValue.updatedAt),
+ syncVersion: fieldValue.syncVersion,
+ _synced: true,
+ };
+}
+
+/**
* Conflict Resolver
*
* Handles conflicts reported by the server during push operations.
@@ -109,7 +208,11 @@ export class ConflictResolver {
hasConflicts(pushResult: SyncPushResult): boolean {
return (
pushResult.conflicts.decks.length > 0 ||
- pushResult.conflicts.cards.length > 0
+ pushResult.conflicts.cards.length > 0 ||
+ pushResult.conflicts.noteTypes.length > 0 ||
+ pushResult.conflicts.noteFieldTypes.length > 0 ||
+ pushResult.conflicts.notes.length > 0 ||
+ pushResult.conflicts.noteFieldValues.length > 0
);
}
@@ -200,6 +303,142 @@ export class ConflictResolver {
}
/**
+ * Resolve note type conflict using configured strategy
+ */
+ async resolveNoteTypeConflict(
+ localNoteType: LocalNoteType,
+ serverNoteType: ServerNoteType,
+ ): Promise<ConflictResolutionItem> {
+ let resolution: "server_wins" | "local_wins";
+
+ switch (this.strategy) {
+ case "server_wins":
+ resolution = "server_wins";
+ break;
+ case "local_wins":
+ resolution = "local_wins";
+ break;
+ case "newer_wins":
+ resolution = isServerNewer(
+ new Date(serverNoteType.updatedAt),
+ localNoteType.updatedAt,
+ )
+ ? "server_wins"
+ : "local_wins";
+ break;
+ }
+
+ if (resolution === "server_wins") {
+ const localData = serverNoteTypeToLocal(serverNoteType);
+ await localNoteTypeRepository.upsertFromServer(localData);
+ }
+
+ return { id: localNoteType.id, resolution };
+ }
+
+ /**
+ * Resolve note field type conflict using configured strategy
+ */
+ async resolveNoteFieldTypeConflict(
+ localFieldType: LocalNoteFieldType,
+ serverFieldType: ServerNoteFieldType,
+ ): Promise<ConflictResolutionItem> {
+ let resolution: "server_wins" | "local_wins";
+
+ switch (this.strategy) {
+ case "server_wins":
+ resolution = "server_wins";
+ break;
+ case "local_wins":
+ resolution = "local_wins";
+ break;
+ case "newer_wins":
+ resolution = isServerNewer(
+ new Date(serverFieldType.updatedAt),
+ localFieldType.updatedAt,
+ )
+ ? "server_wins"
+ : "local_wins";
+ break;
+ }
+
+ if (resolution === "server_wins") {
+ const localData = serverNoteFieldTypeToLocal(serverFieldType);
+ await localNoteFieldTypeRepository.upsertFromServer(localData);
+ }
+
+ return { id: localFieldType.id, resolution };
+ }
+
+ /**
+ * Resolve note conflict using configured strategy
+ */
+ async resolveNoteConflict(
+ localNote: LocalNote,
+ serverNote: ServerNote,
+ ): Promise<ConflictResolutionItem> {
+ let resolution: "server_wins" | "local_wins";
+
+ switch (this.strategy) {
+ case "server_wins":
+ resolution = "server_wins";
+ break;
+ case "local_wins":
+ resolution = "local_wins";
+ break;
+ case "newer_wins":
+ resolution = isServerNewer(
+ new Date(serverNote.updatedAt),
+ localNote.updatedAt,
+ )
+ ? "server_wins"
+ : "local_wins";
+ break;
+ }
+
+ if (resolution === "server_wins") {
+ const localData = serverNoteToLocal(serverNote);
+ await localNoteRepository.upsertFromServer(localData);
+ }
+
+ return { id: localNote.id, resolution };
+ }
+
+ /**
+ * Resolve note field value conflict using configured strategy
+ */
+ async resolveNoteFieldValueConflict(
+ localFieldValue: LocalNoteFieldValue,
+ serverFieldValue: ServerNoteFieldValue,
+ ): Promise<ConflictResolutionItem> {
+ let resolution: "server_wins" | "local_wins";
+
+ switch (this.strategy) {
+ case "server_wins":
+ resolution = "server_wins";
+ break;
+ case "local_wins":
+ resolution = "local_wins";
+ break;
+ case "newer_wins":
+ resolution = isServerNewer(
+ new Date(serverFieldValue.updatedAt),
+ localFieldValue.updatedAt,
+ )
+ ? "server_wins"
+ : "local_wins";
+ break;
+ }
+
+ if (resolution === "server_wins") {
+ const localData = serverNoteFieldValueToLocal(serverFieldValue);
+ await localNoteFieldValueRepository.upsertFromServer(localData);
+ }
+
+ return { id: localFieldValue.id, resolution };
+ }
+
+ /**
* Resolve all conflicts from a push result
* Uses pull result to get server data for conflicting items
*/
@@ -210,6 +449,10 @@ export class ConflictResolver {
const result: ConflictResolutionResult = {
decks: [],
cards: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
};
// Resolve deck conflicts
@@ -252,6 +495,92 @@ export class ConflictResolver {
// If server doesn't have it but local does, keep local (will push again)
}
+ // Resolve note type conflicts
+ for (const noteTypeId of pushResult.conflicts.noteTypes) {
+ const localNoteType = await localNoteTypeRepository.findById(noteTypeId);
+ const serverNoteType = pullResult.noteTypes.find(
+ (nt) => nt.id === noteTypeId,
+ );
+
+ if (localNoteType && serverNoteType) {
+ const resolution = await this.resolveNoteTypeConflict(
+ localNoteType,
+ serverNoteType,
+ );
+ result.noteTypes.push(resolution);
+ } else if (serverNoteType) {
+ const localData = serverNoteTypeToLocal(serverNoteType);
+ await localNoteTypeRepository.upsertFromServer(localData);
+ result.noteTypes.push({ id: noteTypeId, resolution: "server_wins" });
+ }
+ }
+
+ // Resolve note field type conflicts
+ for (const fieldTypeId of pushResult.conflicts.noteFieldTypes) {
+ const localFieldType =
+ await localNoteFieldTypeRepository.findById(fieldTypeId);
+ const serverFieldType = pullResult.noteFieldTypes.find(
+ (ft) => ft.id === fieldTypeId,
+ );
+
+ if (localFieldType && serverFieldType) {
+ const resolution = await this.resolveNoteFieldTypeConflict(
+ localFieldType,
+ serverFieldType,
+ );
+ result.noteFieldTypes.push(resolution);
+ } else if (serverFieldType) {
+ const localData = serverNoteFieldTypeToLocal(serverFieldType);
+ await localNoteFieldTypeRepository.upsertFromServer(localData);
+ result.noteFieldTypes.push({
+ id: fieldTypeId,
+ resolution: "server_wins",
+ });
+ }
+ }
+
+ // Resolve note conflicts
+ for (const noteId of pushResult.conflicts.notes) {
+ const localNote = await localNoteRepository.findById(noteId);
+ const serverNote = pullResult.notes.find((n) => n.id === noteId);
+
+ if (localNote && serverNote) {
+ const resolution = await this.resolveNoteConflict(
+ localNote,
+ serverNote,
+ );
+ result.notes.push(resolution);
+ } else if (serverNote) {
+ const localData = serverNoteToLocal(serverNote);
+ await localNoteRepository.upsertFromServer(localData);
+ result.notes.push({ id: noteId, resolution: "server_wins" });
+ }
+ }
+
+ // Resolve note field value conflicts
+ for (const fieldValueId of pushResult.conflicts.noteFieldValues) {
+ const localFieldValue =
+ await localNoteFieldValueRepository.findById(fieldValueId);
+ const serverFieldValue = pullResult.noteFieldValues.find(
+ (fv) => fv.id === fieldValueId,
+ );
+
+ if (localFieldValue && serverFieldValue) {
+ const resolution = await this.resolveNoteFieldValueConflict(
+ localFieldValue,
+ serverFieldValue,
+ );
+ result.noteFieldValues.push(resolution);
+ } else if (serverFieldValue) {
+ const localData = serverNoteFieldValueToLocal(serverFieldValue);
+ await localNoteFieldValueRepository.upsertFromServer(localData);
+ result.noteFieldValues.push({
+ id: fieldValueId,
+ resolution: "server_wins",
+ });
+ }
+ }
+
return result;
}
}
diff --git a/src/client/sync/manager.test.ts b/src/client/sync/manager.test.ts
index 96fb97d..a3799c0 100644
--- a/src/client/sync/manager.test.ts
+++ b/src/client/sync/manager.test.ts
@@ -19,6 +19,41 @@ import { PullService, type SyncPullResult } from "./pull";
import { PushService, type SyncPushResult } from "./push";
import { SyncQueue } from "./queue";
+function createEmptyConflicts() {
+ return {
+ decks: [] as string[],
+ cards: [] as string[],
+ noteTypes: [] as string[],
+ noteFieldTypes: [] as string[],
+ notes: [] as string[],
+ noteFieldValues: [] as string[],
+ };
+}
+
+function createEmptyPullResult(
+ currentSyncVersion = 0,
+): Omit<SyncPullResult, "decks" | "cards" | "reviewLogs"> {
+ return {
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ currentSyncVersion,
+ };
+}
+
+function createEmptyPushResult(): Omit<
+ SyncPushResult,
+ "decks" | "cards" | "reviewLogs" | "conflicts"
+> {
+ return {
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ };
+}
+
describe("SyncManager", () => {
let syncQueue: SyncQueue;
let conflictResolver: ConflictResolver;
@@ -63,14 +98,15 @@ describe("SyncManager", () => {
decks: [],
cards: [],
reviewLogs: [],
- conflicts: { decks: [], cards: [] },
+ ...createEmptyPushResult(),
+ conflicts: createEmptyConflicts(),
} satisfies SyncPushResult);
pullFromServer = vi.fn().mockResolvedValue({
decks: [],
cards: [],
reviewLogs: [],
- currentSyncVersion: 0,
+ ...createEmptyPullResult(0),
} satisfies SyncPullResult);
conflictResolver = new ConflictResolver({ strategy: "server_wins" });
@@ -249,7 +285,8 @@ describe("SyncManager", () => {
decks: [{ id: "deck-1", syncVersion: 1 }],
cards: [],
reviewLogs: [],
- conflicts: { decks: [], cards: [] },
+ ...createEmptyPushResult(),
+ conflicts: createEmptyConflicts(),
};
pushToServer.mockResolvedValue(expectedPushResult);
@@ -257,7 +294,7 @@ describe("SyncManager", () => {
decks: [],
cards: [],
reviewLogs: [],
- currentSyncVersion: 5,
+ ...createEmptyPullResult(5),
};
pullFromServer.mockResolvedValue(expectedPullResult);
@@ -323,7 +360,8 @@ describe("SyncManager", () => {
decks: [],
cards: [],
reviewLogs: [],
- conflicts: { decks: [], cards: [] },
+ ...createEmptyPushResult(),
+ conflicts: createEmptyConflicts(),
}),
100,
),
@@ -357,7 +395,8 @@ describe("SyncManager", () => {
decks: [{ id: deck.id, syncVersion: 1 }],
cards: [],
reviewLogs: [],
- conflicts: { decks: [deck.id], cards: [] },
+ ...createEmptyPushResult(),
+ conflicts: { ...createEmptyConflicts(), decks: [deck.id] },
};
pushToServer.mockResolvedValue(pushResult);
@@ -377,7 +416,7 @@ describe("SyncManager", () => {
],
cards: [],
reviewLogs: [],
- currentSyncVersion: 5,
+ ...createEmptyPullResult(5),
};
pullFromServer.mockResolvedValue(pullResult);
@@ -573,7 +612,8 @@ describe("SyncManager", () => {
decks: [],
cards: [],
reviewLogs: [],
- conflicts: { decks: [], cards: [] },
+ ...createEmptyPushResult(),
+ conflicts: createEmptyConflicts(),
}),
100,
),
diff --git a/src/client/sync/pull.test.ts b/src/client/sync/pull.test.ts
index 84c22bd..baf4bca 100644
--- a/src/client/sync/pull.test.ts
+++ b/src/client/sync/pull.test.ts
@@ -5,9 +5,21 @@ import "fake-indexeddb/auto";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CardState, db, Rating } from "../db/index";
import { localCardRepository, localDeckRepository } from "../db/repositories";
-import { PullService, pullResultToLocalData } from "./pull";
+import { PullService, pullResultToLocalData, type SyncPullResult } from "./pull";
import { SyncQueue } from "./queue";
+function createEmptyPullResult(
+ currentSyncVersion = 0,
+): Omit<SyncPullResult, "decks" | "cards" | "reviewLogs"> {
+ return {
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ currentSyncVersion,
+ };
+}
+
describe("pullResultToLocalData", () => {
it("should convert server decks to local format", () => {
const serverDecks = [
@@ -28,7 +40,7 @@ describe("pullResultToLocalData", () => {
decks: serverDecks,
cards: [],
reviewLogs: [],
- currentSyncVersion: 5,
+ ...createEmptyPullResult(5),
});
expect(result.decks).toHaveLength(1);
@@ -65,7 +77,7 @@ describe("pullResultToLocalData", () => {
decks: serverDecks,
cards: [],
reviewLogs: [],
- currentSyncVersion: 3,
+ ...createEmptyPullResult(3),
});
expect(result.decks[0]?.deletedAt).toEqual(
@@ -100,7 +112,7 @@ describe("pullResultToLocalData", () => {
decks: [],
cards: serverCards,
reviewLogs: [],
- currentSyncVersion: 2,
+ ...createEmptyPullResult(2),
});
expect(result.cards).toHaveLength(1);
@@ -155,7 +167,7 @@ describe("pullResultToLocalData", () => {
decks: [],
cards: serverCards,
reviewLogs: [],
- currentSyncVersion: 1,
+ ...createEmptyPullResult(1),
});
expect(result.cards[0]?.lastReview).toBeNull();
@@ -181,7 +193,7 @@ describe("pullResultToLocalData", () => {
decks: [],
cards: [],
reviewLogs: serverReviewLogs,
- currentSyncVersion: 1,
+ ...createEmptyPullResult(1),
});
expect(result.reviewLogs).toHaveLength(1);
@@ -220,7 +232,7 @@ describe("pullResultToLocalData", () => {
decks: [],
cards: [],
reviewLogs: serverReviewLogs,
- currentSyncVersion: 1,
+ ...createEmptyPullResult(1),
});
expect(result.reviewLogs[0]?.durationMs).toBeNull();
@@ -251,7 +263,7 @@ describe("PullService", () => {
decks: [],
cards: [],
reviewLogs: [],
- currentSyncVersion: 0,
+ ...createEmptyPullResult(0),
});
const pullService = new PullService({
@@ -265,7 +277,7 @@ describe("PullService", () => {
decks: [],
cards: [],
reviewLogs: [],
- currentSyncVersion: 0,
+ ...createEmptyPullResult(0),
});
expect(pullFromServer).toHaveBeenCalledWith(0);
});
@@ -278,7 +290,7 @@ describe("PullService", () => {
decks: [],
cards: [],
reviewLogs: [],
- currentSyncVersion: 10,
+ ...createEmptyPullResult(10),
});
const pullService = new PullService({
@@ -308,7 +320,7 @@ describe("PullService", () => {
],
cards: [],
reviewLogs: [],
- currentSyncVersion: 5,
+ ...createEmptyPullResult(5),
});
const pullService = new PullService({
@@ -342,6 +354,8 @@ describe("PullService", () => {
{
id: "server-card-1",
deckId: deck.id,
+ noteId: null,
+ isReversed: null,
front: "Server Question",
back: "Server Answer",
state: CardState.New,
@@ -360,7 +374,7 @@ describe("PullService", () => {
},
],
reviewLogs: [],
- currentSyncVersion: 3,
+ ...createEmptyPullResult(3),
});
const pullService = new PullService({
@@ -382,7 +396,7 @@ describe("PullService", () => {
decks: [],
cards: [],
reviewLogs: [],
- currentSyncVersion: 15,
+ ...createEmptyPullResult(15),
});
const pullService = new PullService({
@@ -400,7 +414,7 @@ describe("PullService", () => {
decks: [],
cards: [],
reviewLogs: [],
- currentSyncVersion: 0,
+ ...createEmptyPullResult(0),
});
const pullService = new PullService({
@@ -451,7 +465,7 @@ describe("PullService", () => {
],
cards: [],
reviewLogs: [],
- currentSyncVersion: 10,
+ ...createEmptyPullResult(10),
});
const pullService = new PullService({
@@ -487,6 +501,8 @@ describe("PullService", () => {
{
id: "card-1",
deckId: "deck-1",
+ noteId: null,
+ isReversed: null,
front: "Q",
back: "A",
state: CardState.New,
@@ -518,7 +534,7 @@ describe("PullService", () => {
syncVersion: 3,
},
],
- currentSyncVersion: 3,
+ ...createEmptyPullResult(3),
});
const pullService = new PullService({
diff --git a/src/client/sync/pull.ts b/src/client/sync/pull.ts
index fa0899b..55c859c 100644
--- a/src/client/sync/pull.ts
+++ b/src/client/sync/pull.ts
@@ -1,7 +1,12 @@
import type {
CardStateType,
+ FieldTypeType,
LocalCard,
LocalDeck,
+ LocalNote,
+ LocalNoteFieldType,
+ LocalNoteFieldValue,
+ LocalNoteType,
LocalReviewLog,
RatingType,
} from "../db/index";
@@ -64,12 +69,73 @@ export interface ServerReviewLog {
}
/**
+ * Server note type data format from pull response
+ */
+export interface ServerNoteType {
+ id: string;
+ userId: string;
+ name: string;
+ frontTemplate: string;
+ backTemplate: string;
+ isReversible: boolean;
+ createdAt: Date;
+ updatedAt: Date;
+ deletedAt: Date | null;
+ syncVersion: number;
+}
+
+/**
+ * Server note field type data format from pull response
+ */
+export interface ServerNoteFieldType {
+ id: string;
+ noteTypeId: string;
+ name: string;
+ order: number;
+ fieldType: string;
+ createdAt: Date;
+ updatedAt: Date;
+ deletedAt: Date | null;
+ syncVersion: number;
+}
+
+/**
+ * Server note data format from pull response
+ */
+export interface ServerNote {
+ id: string;
+ deckId: string;
+ noteTypeId: string;
+ createdAt: Date;
+ updatedAt: Date;
+ deletedAt: Date | null;
+ syncVersion: number;
+}
+
+/**
+ * Server note field value data format from pull response
+ */
+export interface ServerNoteFieldValue {
+ id: string;
+ noteId: string;
+ noteFieldTypeId: string;
+ value: string;
+ createdAt: Date;
+ updatedAt: Date;
+ syncVersion: number;
+}
+
+/**
* Response from pull endpoint
*/
export interface SyncPullResult {
decks: ServerDeck[];
cards: ServerCard[];
reviewLogs: ServerReviewLog[];
+ noteTypes: ServerNoteType[];
+ noteFieldTypes: ServerNoteFieldType[];
+ notes: ServerNote[];
+ noteFieldValues: ServerNoteFieldValue[];
currentSyncVersion: number;
}
@@ -147,17 +213,98 @@ function serverReviewLogToLocal(log: ServerReviewLog): LocalReviewLog {
}
/**
+ * Convert server note type to local note type format
+ */
+function serverNoteTypeToLocal(noteType: ServerNoteType): LocalNoteType {
+ return {
+ id: noteType.id,
+ userId: noteType.userId,
+ name: noteType.name,
+ frontTemplate: noteType.frontTemplate,
+ backTemplate: noteType.backTemplate,
+ isReversible: noteType.isReversible,
+ createdAt: new Date(noteType.createdAt),
+ updatedAt: new Date(noteType.updatedAt),
+ deletedAt: noteType.deletedAt ? new Date(noteType.deletedAt) : null,
+ syncVersion: noteType.syncVersion,
+ _synced: true,
+ };
+}
+
+/**
+ * Convert server note field type to local note field type format
+ */
+function serverNoteFieldTypeToLocal(
+ fieldType: ServerNoteFieldType,
+): LocalNoteFieldType {
+ return {
+ id: fieldType.id,
+ noteTypeId: fieldType.noteTypeId,
+ name: fieldType.name,
+ order: fieldType.order,
+ fieldType: fieldType.fieldType as FieldTypeType,
+ createdAt: new Date(fieldType.createdAt),
+ updatedAt: new Date(fieldType.updatedAt),
+ deletedAt: fieldType.deletedAt ? new Date(fieldType.deletedAt) : null,
+ syncVersion: fieldType.syncVersion,
+ _synced: true,
+ };
+}
+
+/**
+ * Convert server note to local note format
+ */
+function serverNoteToLocal(note: ServerNote): LocalNote {
+ return {
+ id: note.id,
+ deckId: note.deckId,
+ noteTypeId: note.noteTypeId,
+ createdAt: new Date(note.createdAt),
+ updatedAt: new Date(note.updatedAt),
+ deletedAt: note.deletedAt ? new Date(note.deletedAt) : null,
+ syncVersion: note.syncVersion,
+ _synced: true,
+ };
+}
+
+/**
+ * Convert server note field value to local note field value format
+ */
+function serverNoteFieldValueToLocal(
+ fieldValue: ServerNoteFieldValue,
+): LocalNoteFieldValue {
+ return {
+ id: fieldValue.id,
+ noteId: fieldValue.noteId,
+ noteFieldTypeId: fieldValue.noteFieldTypeId,
+ value: fieldValue.value,
+ createdAt: new Date(fieldValue.createdAt),
+ updatedAt: new Date(fieldValue.updatedAt),
+ syncVersion: fieldValue.syncVersion,
+ _synced: true,
+ };
+}
+
+/**
* Convert server pull result to local format for storage
*/
export function pullResultToLocalData(result: SyncPullResult): {
decks: LocalDeck[];
cards: LocalCard[];
reviewLogs: LocalReviewLog[];
+ noteTypes: LocalNoteType[];
+ noteFieldTypes: LocalNoteFieldType[];
+ notes: LocalNote[];
+ noteFieldValues: LocalNoteFieldValue[];
} {
return {
decks: result.decks.map(serverDeckToLocal),
cards: result.cards.map(serverCardToLocal),
reviewLogs: result.reviewLogs.map(serverReviewLogToLocal),
+ noteTypes: result.noteTypes.map(serverNoteTypeToLocal),
+ noteFieldTypes: result.noteFieldTypes.map(serverNoteFieldTypeToLocal),
+ notes: result.notes.map(serverNoteToLocal),
+ noteFieldValues: result.noteFieldValues.map(serverNoteFieldValueToLocal),
};
}
@@ -196,7 +343,11 @@ export class PullService {
if (
result.decks.length > 0 ||
result.cards.length > 0 ||
- result.reviewLogs.length > 0
+ result.reviewLogs.length > 0 ||
+ result.noteTypes.length > 0 ||
+ result.noteFieldTypes.length > 0 ||
+ result.notes.length > 0 ||
+ result.noteFieldValues.length > 0
) {
const localData = pullResultToLocalData(result);
await this.syncQueue.applyPulledChanges(localData);
diff --git a/src/client/sync/push.test.ts b/src/client/sync/push.test.ts
index a3ff154..ccd2c7d 100644
--- a/src/client/sync/push.test.ts
+++ b/src/client/sync/push.test.ts
@@ -9,9 +9,57 @@ import {
localDeckRepository,
localReviewLogRepository,
} from "../db/repositories";
+import type { PendingChanges } from "./queue";
import { PushService, pendingChangesToPushData } from "./push";
import { SyncQueue } from "./queue";
+function createEmptyPending(): Omit<
+ PendingChanges,
+ "decks" | "cards" | "reviewLogs"
+> {
+ return {
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ };
+}
+
+function createEmptyConflicts() {
+ return {
+ decks: [] as string[],
+ cards: [] as string[],
+ noteTypes: [] as string[],
+ noteFieldTypes: [] as string[],
+ notes: [] as string[],
+ noteFieldValues: [] as string[],
+ };
+}
+
+function createEmptyPushResult(): Omit<
+ import("./push").SyncPushResult,
+ "decks" | "cards" | "reviewLogs" | "conflicts"
+> {
+ return {
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ };
+}
+
+function createEmptyPushData(): Omit<
+ import("./push").SyncPushData,
+ "decks" | "cards" | "reviewLogs"
+> {
+ return {
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ };
+}
+
describe("pendingChangesToPushData", () => {
it("should convert decks to sync format", () => {
const decks = [
@@ -33,6 +81,7 @@ describe("pendingChangesToPushData", () => {
decks,
cards: [],
reviewLogs: [],
+ ...createEmptyPending(),
});
expect(result.decks).toHaveLength(1);
@@ -67,6 +116,7 @@ describe("pendingChangesToPushData", () => {
decks,
cards: [],
reviewLogs: [],
+ ...createEmptyPending(),
});
expect(result.decks[0]?.deletedAt).toBe("2024-01-03T12:00:00.000Z");
@@ -102,12 +152,15 @@ describe("pendingChangesToPushData", () => {
decks: [],
cards,
reviewLogs: [],
+ ...createEmptyPending(),
});
expect(result.cards).toHaveLength(1);
expect(result.cards[0]).toEqual({
id: "card-1",
deckId: "deck-1",
+ noteId: null,
+ isReversed: null,
front: "Question",
back: "Answer",
state: CardState.Review,
@@ -155,6 +208,7 @@ describe("pendingChangesToPushData", () => {
decks: [],
cards,
reviewLogs: [],
+ ...createEmptyPending(),
});
expect(result.cards[0]?.lastReview).toBeNull();
@@ -181,6 +235,7 @@ describe("pendingChangesToPushData", () => {
decks: [],
cards: [],
reviewLogs,
+ ...createEmptyPending(),
});
expect(result.reviewLogs).toHaveLength(1);
@@ -217,6 +272,7 @@ describe("pendingChangesToPushData", () => {
decks: [],
cards: [],
reviewLogs,
+ ...createEmptyPending(),
});
expect(result.reviewLogs[0]?.durationMs).toBeNull();
@@ -255,7 +311,8 @@ describe("PushService", () => {
decks: [],
cards: [],
reviewLogs: [],
- conflicts: { decks: [], cards: [] },
+ ...createEmptyPushResult(),
+ conflicts: createEmptyConflicts(),
});
expect(pushToServer).not.toHaveBeenCalled();
});
@@ -272,7 +329,8 @@ describe("PushService", () => {
decks: [{ id: deck.id, syncVersion: 1 }],
cards: [],
reviewLogs: [],
- conflicts: { decks: [], cards: [] },
+ ...createEmptyPushResult(),
+ conflicts: createEmptyConflicts(),
});
const pushService = new PushService({
@@ -292,6 +350,7 @@ describe("PushService", () => {
],
cards: [],
reviewLogs: [],
+ ...createEmptyPushData(),
});
expect(result.decks).toHaveLength(1);
expect(result.decks[0]?.id).toBe(deck.id);
@@ -316,7 +375,8 @@ describe("PushService", () => {
decks: [],
cards: [{ id: card.id, syncVersion: 1 }],
reviewLogs: [],
- conflicts: { decks: [], cards: [] },
+ ...createEmptyPushResult(),
+ conflicts: createEmptyConflicts(),
});
const pushService = new PushService({
@@ -336,6 +396,7 @@ describe("PushService", () => {
}),
],
reviewLogs: [],
+ ...createEmptyPushData(),
});
expect(result.cards).toHaveLength(1);
});
@@ -371,7 +432,8 @@ describe("PushService", () => {
decks: [],
cards: [],
reviewLogs: [{ id: log.id, syncVersion: 1 }],
- conflicts: { decks: [], cards: [] },
+ ...createEmptyPushResult(),
+ conflicts: createEmptyConflicts(),
});
const pushService = new PushService({
@@ -390,6 +452,7 @@ describe("PushService", () => {
rating: Rating.Good,
}),
],
+ ...createEmptyPushData(),
});
expect(result.reviewLogs).toHaveLength(1);
});
@@ -406,7 +469,8 @@ describe("PushService", () => {
decks: [{ id: deck.id, syncVersion: 5 }],
cards: [],
reviewLogs: [],
- conflicts: { decks: [], cards: [] },
+ ...createEmptyPushResult(),
+ conflicts: createEmptyConflicts(),
});
const pushService = new PushService({
@@ -433,7 +497,8 @@ describe("PushService", () => {
decks: [{ id: deck.id, syncVersion: 3 }],
cards: [],
reviewLogs: [],
- conflicts: { decks: [deck.id], cards: [] },
+ ...createEmptyPushResult(),
+ conflicts: { ...createEmptyConflicts(), decks: [deck.id] },
});
const pushService = new PushService({
@@ -495,7 +560,8 @@ describe("PushService", () => {
decks: [{ id: deck.id, syncVersion: 1 }],
cards: [{ id: card.id, syncVersion: 1 }],
reviewLogs: [{ id: log.id, syncVersion: 1 }],
- conflicts: { decks: [], cards: [] },
+ ...createEmptyPushResult(),
+ conflicts: createEmptyConflicts(),
});
const pushService = new PushService({
diff --git a/src/client/sync/push.ts b/src/client/sync/push.ts
index 2493e4e..f5c9275 100644
--- a/src/client/sync/push.ts
+++ b/src/client/sync/push.ts
@@ -1,4 +1,12 @@
-import type { LocalCard, LocalDeck, LocalReviewLog } from "../db/index";
+import type {
+ LocalCard,
+ LocalDeck,
+ LocalNote,
+ LocalNoteFieldType,
+ LocalNoteFieldValue,
+ LocalNoteType,
+ LocalReviewLog,
+} from "../db/index";
import type { PendingChanges, SyncQueue } from "./queue";
/**
@@ -8,6 +16,10 @@ export interface SyncPushData {
decks: SyncDeckData[];
cards: SyncCardData[];
reviewLogs: SyncReviewLogData[];
+ noteTypes: SyncNoteTypeData[];
+ noteFieldTypes: SyncNoteFieldTypeData[];
+ notes: SyncNoteData[];
+ noteFieldValues: SyncNoteFieldValueData[];
}
export interface SyncDeckData {
@@ -23,6 +35,8 @@ export interface SyncDeckData {
export interface SyncCardData {
id: string;
deckId: string;
+ noteId: string | null;
+ isReversed: boolean | null;
front: string;
back: string;
state: number;
@@ -50,6 +64,46 @@ export interface SyncReviewLogData {
durationMs: number | null;
}
+export interface SyncNoteTypeData {
+ id: string;
+ name: string;
+ frontTemplate: string;
+ backTemplate: string;
+ isReversible: boolean;
+ createdAt: string;
+ updatedAt: string;
+ deletedAt: string | null;
+}
+
+export interface SyncNoteFieldTypeData {
+ id: string;
+ noteTypeId: string;
+ name: string;
+ order: number;
+ fieldType: string;
+ createdAt: string;
+ updatedAt: string;
+ deletedAt: string | null;
+}
+
+export interface SyncNoteData {
+ id: string;
+ deckId: string;
+ noteTypeId: string;
+ createdAt: string;
+ updatedAt: string;
+ deletedAt: string | null;
+}
+
+export interface SyncNoteFieldValueData {
+ id: string;
+ noteId: string;
+ noteFieldTypeId: string;
+ value: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
/**
* Response from push endpoint
*/
@@ -57,9 +111,17 @@ export interface SyncPushResult {
decks: { id: string; syncVersion: number }[];
cards: { id: string; syncVersion: number }[];
reviewLogs: { id: string; syncVersion: number }[];
+ noteTypes: { id: string; syncVersion: number }[];
+ noteFieldTypes: { id: string; syncVersion: number }[];
+ notes: { id: string; syncVersion: number }[];
+ noteFieldValues: { id: string; syncVersion: number }[];
conflicts: {
decks: string[];
cards: string[];
+ noteTypes: string[];
+ noteFieldTypes: string[];
+ notes: string[];
+ noteFieldValues: string[];
};
}
@@ -93,6 +155,8 @@ function cardToSyncData(card: LocalCard): SyncCardData {
return {
id: card.id,
deckId: card.deckId,
+ noteId: card.noteId,
+ isReversed: card.isReversed,
front: card.front,
back: card.back,
state: card.state,
@@ -127,6 +191,70 @@ function reviewLogToSyncData(log: LocalReviewLog): SyncReviewLogData {
}
/**
+ * Convert local note type to sync format
+ */
+function noteTypeToSyncData(noteType: LocalNoteType): SyncNoteTypeData {
+ return {
+ id: noteType.id,
+ name: noteType.name,
+ frontTemplate: noteType.frontTemplate,
+ backTemplate: noteType.backTemplate,
+ isReversible: noteType.isReversible,
+ createdAt: noteType.createdAt.toISOString(),
+ updatedAt: noteType.updatedAt.toISOString(),
+ deletedAt: noteType.deletedAt?.toISOString() ?? null,
+ };
+}
+
+/**
+ * Convert local note field type to sync format
+ */
+function noteFieldTypeToSyncData(
+ fieldType: LocalNoteFieldType,
+): SyncNoteFieldTypeData {
+ return {
+ id: fieldType.id,
+ noteTypeId: fieldType.noteTypeId,
+ name: fieldType.name,
+ order: fieldType.order,
+ fieldType: fieldType.fieldType,
+ createdAt: fieldType.createdAt.toISOString(),
+ updatedAt: fieldType.updatedAt.toISOString(),
+ deletedAt: fieldType.deletedAt?.toISOString() ?? null,
+ };
+}
+
+/**
+ * Convert local note to sync format
+ */
+function noteToSyncData(note: LocalNote): SyncNoteData {
+ return {
+ id: note.id,
+ deckId: note.deckId,
+ noteTypeId: note.noteTypeId,
+ createdAt: note.createdAt.toISOString(),
+ updatedAt: note.updatedAt.toISOString(),
+ deletedAt: note.deletedAt?.toISOString() ?? null,
+ };
+}
+
+/**
+ * Convert local note field value to sync format
+ */
+function noteFieldValueToSyncData(
+ fieldValue: LocalNoteFieldValue,
+): SyncNoteFieldValueData {
+ return {
+ id: fieldValue.id,
+ noteId: fieldValue.noteId,
+ noteFieldTypeId: fieldValue.noteFieldTypeId,
+ value: fieldValue.value,
+ createdAt: fieldValue.createdAt.toISOString(),
+ updatedAt: fieldValue.updatedAt.toISOString(),
+ };
+}
+
+/**
* Convert pending changes to sync push data format
*/
export function pendingChangesToPushData(
@@ -136,6 +264,10 @@ export function pendingChangesToPushData(
decks: changes.decks.map(deckToSyncData),
cards: changes.cards.map(cardToSyncData),
reviewLogs: changes.reviewLogs.map(reviewLogToSyncData),
+ noteTypes: changes.noteTypes.map(noteTypeToSyncData),
+ noteFieldTypes: changes.noteFieldTypes.map(noteFieldTypeToSyncData),
+ notes: changes.notes.map(noteToSyncData),
+ noteFieldValues: changes.noteFieldValues.map(noteFieldValueToSyncData),
};
}
@@ -171,13 +303,28 @@ export class PushService {
if (
pendingChanges.decks.length === 0 &&
pendingChanges.cards.length === 0 &&
- pendingChanges.reviewLogs.length === 0
+ pendingChanges.reviewLogs.length === 0 &&
+ pendingChanges.noteTypes.length === 0 &&
+ pendingChanges.noteFieldTypes.length === 0 &&
+ pendingChanges.notes.length === 0 &&
+ pendingChanges.noteFieldValues.length === 0
) {
return {
decks: [],
cards: [],
reviewLogs: [],
- conflicts: { decks: [], cards: [] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: {
+ decks: [],
+ cards: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ },
};
}
@@ -192,6 +339,10 @@ export class PushService {
decks: result.decks,
cards: result.cards,
reviewLogs: result.reviewLogs,
+ noteTypes: result.noteTypes,
+ noteFieldTypes: result.noteFieldTypes,
+ notes: result.notes,
+ noteFieldValues: result.noteFieldValues,
});
return result;
diff --git a/src/client/sync/queue.test.ts b/src/client/sync/queue.test.ts
index b62ece2..2038e0d 100644
--- a/src/client/sync/queue.test.ts
+++ b/src/client/sync/queue.test.ts
@@ -303,6 +303,10 @@ describe("SyncQueue", () => {
decks: [{ id: deck.id, syncVersion: 5 }],
cards: [],
reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
});
const found = await localDeckRepository.findById(deck.id);
@@ -327,6 +331,10 @@ describe("SyncQueue", () => {
decks: [],
cards: [{ id: card.id, syncVersion: 3 }],
reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
});
const found = await localCardRepository.findById(card.id);
@@ -361,6 +369,10 @@ describe("SyncQueue", () => {
decks: [],
cards: [],
reviewLogs: [{ id: reviewLog.id, syncVersion: 2 }],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
});
const found = await localReviewLogRepository.findById(reviewLog.id);
@@ -376,6 +388,10 @@ describe("SyncQueue", () => {
decks: [],
cards: [],
reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
});
expect(listener).toHaveBeenCalled();
@@ -401,6 +417,10 @@ describe("SyncQueue", () => {
decks: [serverDeck],
cards: [],
reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
});
const found = await localDeckRepository.findById("server-deck-1");
@@ -444,6 +464,10 @@ describe("SyncQueue", () => {
decks: [],
cards: [serverCard],
reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
});
const found = await localCardRepository.findById("server-card-1");
@@ -482,6 +506,10 @@ describe("SyncQueue", () => {
decks: [],
cards: [],
reviewLogs: [serverLog],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
});
const found = await localReviewLogRepository.findById("server-log-1");
@@ -497,6 +525,10 @@ describe("SyncQueue", () => {
decks: [],
cards: [],
reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
});
expect(listener).toHaveBeenCalled();
diff --git a/src/client/sync/queue.ts b/src/client/sync/queue.ts
index 01c62cc..984edc3 100644
--- a/src/client/sync/queue.ts
+++ b/src/client/sync/queue.ts
@@ -2,11 +2,19 @@ import {
db,
type LocalCard,
type LocalDeck,
+ type LocalNote,
+ type LocalNoteFieldType,
+ type LocalNoteFieldValue,
+ type LocalNoteType,
type LocalReviewLog,
} from "../db/index";
import {
localCardRepository,
localDeckRepository,
+ localNoteFieldTypeRepository,
+ localNoteFieldValueRepository,
+ localNoteRepository,
+ localNoteTypeRepository,
localReviewLogRepository,
} from "../db/repositories";
@@ -28,6 +36,10 @@ export interface PendingChanges {
decks: LocalDeck[];
cards: LocalCard[];
reviewLogs: LocalReviewLog[];
+ noteTypes: LocalNoteType[];
+ noteFieldTypes: LocalNoteFieldType[];
+ notes: LocalNote[];
+ noteFieldValues: LocalNoteFieldValue[];
}
/**
@@ -131,13 +143,33 @@ export class SyncQueue {
* Get all pending (unsynced) changes
*/
async getPendingChanges(): Promise<PendingChanges> {
- const [decks, cards, reviewLogs] = await Promise.all([
+ const [
+ decks,
+ cards,
+ reviewLogs,
+ noteTypes,
+ noteFieldTypes,
+ notes,
+ noteFieldValues,
+ ] = await Promise.all([
localDeckRepository.findUnsynced(),
localCardRepository.findUnsynced(),
localReviewLogRepository.findUnsynced(),
+ localNoteTypeRepository.findUnsynced(),
+ localNoteFieldTypeRepository.findUnsynced(),
+ localNoteRepository.findUnsynced(),
+ localNoteFieldValueRepository.findUnsynced(),
]);
- return { decks, cards, reviewLogs };
+ return {
+ decks,
+ cards,
+ reviewLogs,
+ noteTypes,
+ noteFieldTypes,
+ notes,
+ noteFieldValues,
+ };
}
/**
@@ -146,7 +178,13 @@ export class SyncQueue {
async getPendingCount(): Promise<number> {
const changes = await this.getPendingChanges();
return (
- changes.decks.length + changes.cards.length + changes.reviewLogs.length
+ changes.decks.length +
+ changes.cards.length +
+ changes.reviewLogs.length +
+ changes.noteTypes.length +
+ changes.noteFieldTypes.length +
+ changes.notes.length +
+ changes.noteFieldValues.length
);
}
@@ -214,10 +252,22 @@ export class SyncQueue {
decks: { id: string; syncVersion: number }[];
cards: { id: string; syncVersion: number }[];
reviewLogs: { id: string; syncVersion: number }[];
+ noteTypes: { id: string; syncVersion: number }[];
+ noteFieldTypes: { id: string; syncVersion: number }[];
+ notes: { id: string; syncVersion: number }[];
+ noteFieldValues: { id: string; syncVersion: number }[];
}): Promise<void> {
await db.transaction(
"rw",
- [db.decks, db.cards, db.reviewLogs],
+ [
+ db.decks,
+ db.cards,
+ db.reviewLogs,
+ db.noteTypes,
+ db.noteFieldTypes,
+ db.notes,
+ db.noteFieldValues,
+ ],
async () => {
for (const deck of results.decks) {
await localDeckRepository.markSynced(deck.id, deck.syncVersion);
@@ -231,6 +281,27 @@ export class SyncQueue {
reviewLog.syncVersion,
);
}
+ for (const noteType of results.noteTypes) {
+ await localNoteTypeRepository.markSynced(
+ noteType.id,
+ noteType.syncVersion,
+ );
+ }
+ for (const fieldType of results.noteFieldTypes) {
+ await localNoteFieldTypeRepository.markSynced(
+ fieldType.id,
+ fieldType.syncVersion,
+ );
+ }
+ for (const note of results.notes) {
+ await localNoteRepository.markSynced(note.id, note.syncVersion);
+ }
+ for (const fieldValue of results.noteFieldValues) {
+ await localNoteFieldValueRepository.markSynced(
+ fieldValue.id,
+ fieldValue.syncVersion,
+ );
+ }
},
);
await this.notifyListeners();
@@ -243,14 +314,39 @@ export class SyncQueue {
decks: LocalDeck[];
cards: LocalCard[];
reviewLogs: LocalReviewLog[];
+ noteTypes: LocalNoteType[];
+ noteFieldTypes: LocalNoteFieldType[];
+ notes: LocalNote[];
+ noteFieldValues: LocalNoteFieldValue[];
}): Promise<void> {
await db.transaction(
"rw",
- [db.decks, db.cards, db.reviewLogs],
+ [
+ db.decks,
+ db.cards,
+ db.reviewLogs,
+ db.noteTypes,
+ db.noteFieldTypes,
+ db.notes,
+ db.noteFieldValues,
+ ],
async () => {
+ // Apply in dependency order: NoteTypes first, then dependent entities
+ for (const noteType of data.noteTypes) {
+ await localNoteTypeRepository.upsertFromServer(noteType);
+ }
+ for (const fieldType of data.noteFieldTypes) {
+ await localNoteFieldTypeRepository.upsertFromServer(fieldType);
+ }
for (const deck of data.decks) {
await localDeckRepository.upsertFromServer(deck);
}
+ for (const note of data.notes) {
+ await localNoteRepository.upsertFromServer(note);
+ }
+ for (const fieldValue of data.noteFieldValues) {
+ await localNoteFieldValueRepository.upsertFromServer(fieldValue);
+ }
for (const card of data.cards) {
await localCardRepository.upsertFromServer(card);
}
diff --git a/src/server/repositories/sync.ts b/src/server/repositories/sync.ts
index 3e9f8ed..ac9b336 100644
--- a/src/server/repositories/sync.ts
+++ b/src/server/repositories/sync.ts
@@ -1,7 +1,23 @@
import { and, eq, gt, sql } from "drizzle-orm";
import { db } from "../db/index.js";
-import { cards, decks, reviewLogs } from "../db/schema.js";
-import type { Card, Deck, ReviewLog } from "./types.js";
+import {
+ cards,
+ decks,
+ noteFieldTypes,
+ noteFieldValues,
+ noteTypes,
+ notes,
+ reviewLogs,
+} from "../db/schema.js";
+import type {
+ Card,
+ Deck,
+ Note,
+ NoteFieldType,
+ NoteFieldValue,
+ NoteType,
+ ReviewLog,
+} from "./types.js";
/**
* Sync data types for push/pull operations
@@ -10,6 +26,10 @@ export interface SyncPushData {
decks: SyncDeckData[];
cards: SyncCardData[];
reviewLogs: SyncReviewLogData[];
+ noteTypes: SyncNoteTypeData[];
+ noteFieldTypes: SyncNoteFieldTypeData[];
+ notes: SyncNoteData[];
+ noteFieldValues: SyncNoteFieldValueData[];
}
export interface SyncDeckData {
@@ -25,6 +45,8 @@ export interface SyncDeckData {
export interface SyncCardData {
id: string;
deckId: string;
+ noteId: string | null;
+ isReversed: boolean | null;
front: string;
back: string;
state: number;
@@ -52,13 +74,61 @@ export interface SyncReviewLogData {
durationMs: number | null;
}
+export interface SyncNoteTypeData {
+ id: string;
+ name: string;
+ frontTemplate: string;
+ backTemplate: string;
+ isReversible: boolean;
+ createdAt: string;
+ updatedAt: string;
+ deletedAt: string | null;
+}
+
+export interface SyncNoteFieldTypeData {
+ id: string;
+ noteTypeId: string;
+ name: string;
+ order: number;
+ fieldType: string;
+ createdAt: string;
+ updatedAt: string;
+ deletedAt: string | null;
+}
+
+export interface SyncNoteData {
+ id: string;
+ deckId: string;
+ noteTypeId: string;
+ createdAt: string;
+ updatedAt: string;
+ deletedAt: string | null;
+}
+
+export interface SyncNoteFieldValueData {
+ id: string;
+ noteId: string;
+ noteFieldTypeId: string;
+ value: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
export interface SyncPushResult {
decks: { id: string; syncVersion: number }[];
cards: { id: string; syncVersion: number }[];
reviewLogs: { id: string; syncVersion: number }[];
+ noteTypes: { id: string; syncVersion: number }[];
+ noteFieldTypes: { id: string; syncVersion: number }[];
+ notes: { id: string; syncVersion: number }[];
+ noteFieldValues: { id: string; syncVersion: number }[];
conflicts: {
decks: string[];
cards: string[];
+ noteTypes: string[];
+ noteFieldTypes: string[];
+ notes: string[];
+ noteFieldValues: string[];
};
}
@@ -70,6 +140,10 @@ export interface SyncPullResult {
decks: Deck[];
cards: Card[];
reviewLogs: ReviewLog[];
+ noteTypes: NoteType[];
+ noteFieldTypes: NoteFieldType[];
+ notes: Note[];
+ noteFieldValues: NoteFieldValue[];
currentSyncVersion: number;
}
@@ -87,9 +161,17 @@ export const syncRepository: SyncRepository = {
decks: [],
cards: [],
reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
conflicts: {
decks: [],
cards: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
},
};
@@ -200,6 +282,8 @@ export const syncRepository: SyncRepository = {
.values({
id: cardData.id,
deckId: cardData.deckId,
+ noteId: cardData.noteId,
+ isReversed: cardData.isReversed,
front: cardData.front,
back: cardData.back,
state: cardData.state,
@@ -235,6 +319,8 @@ export const syncRepository: SyncRepository = {
.update(cards)
.set({
deckId: cardData.deckId,
+ noteId: cardData.noteId,
+ isReversed: cardData.isReversed,
front: cardData.front,
back: cardData.back,
state: cardData.state,
@@ -333,6 +419,357 @@ export const syncRepository: SyncRepository = {
}
}
+ // Process note types with Last-Write-Wins conflict resolution
+ for (const noteTypeData of data.noteTypes) {
+ const clientUpdatedAt = new Date(noteTypeData.updatedAt);
+
+ // Check if note type exists and belongs to user
+ const existing = await db
+ .select({
+ id: noteTypes.id,
+ updatedAt: noteTypes.updatedAt,
+ syncVersion: noteTypes.syncVersion,
+ })
+ .from(noteTypes)
+ .where(
+ and(eq(noteTypes.id, noteTypeData.id), eq(noteTypes.userId, userId)),
+ );
+
+ if (existing.length === 0) {
+ // New note type - insert
+ const [inserted] = await db
+ .insert(noteTypes)
+ .values({
+ id: noteTypeData.id,
+ userId,
+ name: noteTypeData.name,
+ frontTemplate: noteTypeData.frontTemplate,
+ backTemplate: noteTypeData.backTemplate,
+ isReversible: noteTypeData.isReversible,
+ createdAt: new Date(noteTypeData.createdAt),
+ updatedAt: clientUpdatedAt,
+ deletedAt: noteTypeData.deletedAt
+ ? new Date(noteTypeData.deletedAt)
+ : null,
+ syncVersion: 1,
+ })
+ .returning({ id: noteTypes.id, syncVersion: noteTypes.syncVersion });
+
+ if (inserted) {
+ result.noteTypes.push({
+ id: inserted.id,
+ syncVersion: inserted.syncVersion,
+ });
+ }
+ } else {
+ const serverNoteType = existing[0];
+ if (serverNoteType && clientUpdatedAt > serverNoteType.updatedAt) {
+ // Client wins - update
+ const [updated] = await db
+ .update(noteTypes)
+ .set({
+ name: noteTypeData.name,
+ frontTemplate: noteTypeData.frontTemplate,
+ backTemplate: noteTypeData.backTemplate,
+ isReversible: noteTypeData.isReversible,
+ updatedAt: clientUpdatedAt,
+ deletedAt: noteTypeData.deletedAt
+ ? new Date(noteTypeData.deletedAt)
+ : null,
+ syncVersion: sql`${noteTypes.syncVersion} + 1`,
+ })
+ .where(eq(noteTypes.id, noteTypeData.id))
+ .returning({
+ id: noteTypes.id,
+ syncVersion: noteTypes.syncVersion,
+ });
+
+ if (updated) {
+ result.noteTypes.push({
+ id: updated.id,
+ syncVersion: updated.syncVersion,
+ });
+ }
+ } else if (serverNoteType) {
+ // Server wins - mark as conflict
+ result.conflicts.noteTypes.push(noteTypeData.id);
+ result.noteTypes.push({
+ id: serverNoteType.id,
+ syncVersion: serverNoteType.syncVersion,
+ });
+ }
+ }
+ }
+
+ // Process note field types with Last-Write-Wins conflict resolution
+ for (const fieldTypeData of data.noteFieldTypes) {
+ const clientUpdatedAt = new Date(fieldTypeData.updatedAt);
+
+ // Verify parent note type belongs to user
+ const noteTypeCheck = await db
+ .select({ id: noteTypes.id })
+ .from(noteTypes)
+ .where(
+ and(
+ eq(noteTypes.id, fieldTypeData.noteTypeId),
+ eq(noteTypes.userId, userId),
+ ),
+ );
+
+ if (noteTypeCheck.length === 0) {
+ // Parent note type doesn't belong to user, skip
+ continue;
+ }
+
+ // Check if note field type exists
+ const existing = await db
+ .select({
+ id: noteFieldTypes.id,
+ updatedAt: noteFieldTypes.updatedAt,
+ syncVersion: noteFieldTypes.syncVersion,
+ })
+ .from(noteFieldTypes)
+ .where(eq(noteFieldTypes.id, fieldTypeData.id));
+
+ if (existing.length === 0) {
+ // New note field type - insert
+ const [inserted] = await db
+ .insert(noteFieldTypes)
+ .values({
+ id: fieldTypeData.id,
+ noteTypeId: fieldTypeData.noteTypeId,
+ name: fieldTypeData.name,
+ order: fieldTypeData.order,
+ fieldType: fieldTypeData.fieldType,
+ createdAt: new Date(fieldTypeData.createdAt),
+ updatedAt: clientUpdatedAt,
+ deletedAt: fieldTypeData.deletedAt
+ ? new Date(fieldTypeData.deletedAt)
+ : null,
+ syncVersion: 1,
+ })
+ .returning({
+ id: noteFieldTypes.id,
+ syncVersion: noteFieldTypes.syncVersion,
+ });
+
+ if (inserted) {
+ result.noteFieldTypes.push({
+ id: inserted.id,
+ syncVersion: inserted.syncVersion,
+ });
+ }
+ } else {
+ const serverFieldType = existing[0];
+ if (serverFieldType && clientUpdatedAt > serverFieldType.updatedAt) {
+ // Client wins - update
+ const [updated] = await db
+ .update(noteFieldTypes)
+ .set({
+ noteTypeId: fieldTypeData.noteTypeId,
+ name: fieldTypeData.name,
+ order: fieldTypeData.order,
+ fieldType: fieldTypeData.fieldType,
+ updatedAt: clientUpdatedAt,
+ deletedAt: fieldTypeData.deletedAt
+ ? new Date(fieldTypeData.deletedAt)
+ : null,
+ syncVersion: sql`${noteFieldTypes.syncVersion} + 1`,
+ })
+ .where(eq(noteFieldTypes.id, fieldTypeData.id))
+ .returning({
+ id: noteFieldTypes.id,
+ syncVersion: noteFieldTypes.syncVersion,
+ });
+
+ if (updated) {
+ result.noteFieldTypes.push({
+ id: updated.id,
+ syncVersion: updated.syncVersion,
+ });
+ }
+ } else if (serverFieldType) {
+ // Server wins - mark as conflict
+ result.conflicts.noteFieldTypes.push(fieldTypeData.id);
+ result.noteFieldTypes.push({
+ id: serverFieldType.id,
+ syncVersion: serverFieldType.syncVersion,
+ });
+ }
+ }
+ }
+
+ // Process notes with Last-Write-Wins conflict resolution
+ for (const noteData of data.notes) {
+ const clientUpdatedAt = new Date(noteData.updatedAt);
+
+ // Verify parent deck belongs to user
+ const deckCheck = await db
+ .select({ id: decks.id })
+ .from(decks)
+ .where(and(eq(decks.id, noteData.deckId), eq(decks.userId, userId)));
+
+ if (deckCheck.length === 0) {
+ // Parent deck doesn't belong to user, skip
+ continue;
+ }
+
+ // Check if note exists
+ const existing = await db
+ .select({
+ id: notes.id,
+ updatedAt: notes.updatedAt,
+ syncVersion: notes.syncVersion,
+ })
+ .from(notes)
+ .where(eq(notes.id, noteData.id));
+
+ if (existing.length === 0) {
+ // New note - insert
+ const [inserted] = await db
+ .insert(notes)
+ .values({
+ id: noteData.id,
+ deckId: noteData.deckId,
+ noteTypeId: noteData.noteTypeId,
+ createdAt: new Date(noteData.createdAt),
+ updatedAt: clientUpdatedAt,
+ deletedAt: noteData.deletedAt
+ ? new Date(noteData.deletedAt)
+ : null,
+ syncVersion: 1,
+ })
+ .returning({ id: notes.id, syncVersion: notes.syncVersion });
+
+ if (inserted) {
+ result.notes.push({
+ id: inserted.id,
+ syncVersion: inserted.syncVersion,
+ });
+ }
+ } else {
+ const serverNote = existing[0];
+ if (serverNote && clientUpdatedAt > serverNote.updatedAt) {
+ // Client wins - update
+ const [updated] = await db
+ .update(notes)
+ .set({
+ deckId: noteData.deckId,
+ noteTypeId: noteData.noteTypeId,
+ updatedAt: clientUpdatedAt,
+ deletedAt: noteData.deletedAt
+ ? new Date(noteData.deletedAt)
+ : null,
+ syncVersion: sql`${notes.syncVersion} + 1`,
+ })
+ .where(eq(notes.id, noteData.id))
+ .returning({ id: notes.id, syncVersion: notes.syncVersion });
+
+ if (updated) {
+ result.notes.push({
+ id: updated.id,
+ syncVersion: updated.syncVersion,
+ });
+ }
+ } else if (serverNote) {
+ // Server wins - mark as conflict
+ result.conflicts.notes.push(noteData.id);
+ result.notes.push({
+ id: serverNote.id,
+ syncVersion: serverNote.syncVersion,
+ });
+ }
+ }
+ }
+
+ // Process note field values with Last-Write-Wins conflict resolution
+ for (const fieldValueData of data.noteFieldValues) {
+ const clientUpdatedAt = new Date(fieldValueData.updatedAt);
+
+ // Verify parent note belongs to user (via deck ownership)
+ const noteCheck = await db
+ .select({ id: notes.id })
+ .from(notes)
+ .innerJoin(decks, eq(notes.deckId, decks.id))
+ .where(
+ and(eq(notes.id, fieldValueData.noteId), eq(decks.userId, userId)),
+ );
+
+ if (noteCheck.length === 0) {
+ // Parent note doesn't belong to user, skip
+ continue;
+ }
+
+ // Check if note field value exists
+ const existing = await db
+ .select({
+ id: noteFieldValues.id,
+ updatedAt: noteFieldValues.updatedAt,
+ syncVersion: noteFieldValues.syncVersion,
+ })
+ .from(noteFieldValues)
+ .where(eq(noteFieldValues.id, fieldValueData.id));
+
+ if (existing.length === 0) {
+ // New note field value - insert
+ const [inserted] = await db
+ .insert(noteFieldValues)
+ .values({
+ id: fieldValueData.id,
+ noteId: fieldValueData.noteId,
+ noteFieldTypeId: fieldValueData.noteFieldTypeId,
+ value: fieldValueData.value,
+ createdAt: new Date(fieldValueData.createdAt),
+ updatedAt: clientUpdatedAt,
+ syncVersion: 1,
+ })
+ .returning({
+ id: noteFieldValues.id,
+ syncVersion: noteFieldValues.syncVersion,
+ });
+
+ if (inserted) {
+ result.noteFieldValues.push({
+ id: inserted.id,
+ syncVersion: inserted.syncVersion,
+ });
+ }
+ } else {
+ const serverFieldValue = existing[0];
+ if (serverFieldValue && clientUpdatedAt > serverFieldValue.updatedAt) {
+ // Client wins - update
+ const [updated] = await db
+ .update(noteFieldValues)
+ .set({
+ noteId: fieldValueData.noteId,
+ noteFieldTypeId: fieldValueData.noteFieldTypeId,
+ value: fieldValueData.value,
+ updatedAt: clientUpdatedAt,
+ syncVersion: sql`${noteFieldValues.syncVersion} + 1`,
+ })
+ .where(eq(noteFieldValues.id, fieldValueData.id))
+ .returning({
+ id: noteFieldValues.id,
+ syncVersion: noteFieldValues.syncVersion,
+ });
+
+ if (updated) {
+ result.noteFieldValues.push({
+ id: updated.id,
+ syncVersion: updated.syncVersion,
+ });
+ }
+ } else if (serverFieldValue) {
+ // Server wins - mark as conflict
+ result.conflicts.noteFieldValues.push(fieldValueData.id);
+ result.noteFieldValues.push({
+ id: serverFieldValue.id,
+ syncVersion: serverFieldValue.syncVersion,
+ });
+ }
+ }
+ }
+
return result;
},
@@ -380,6 +817,76 @@ export const syncRepository: SyncRepository = {
),
);
+ // Get all note types for user with syncVersion > lastSyncVersion
+ const pulledNoteTypes = await db
+ .select()
+ .from(noteTypes)
+ .where(
+ and(
+ eq(noteTypes.userId, userId),
+ gt(noteTypes.syncVersion, lastSyncVersion),
+ ),
+ );
+
+ // Get user's note type IDs for filtering note field types
+ const userNoteTypeIds = await db
+ .select({ id: noteTypes.id })
+ .from(noteTypes)
+ .where(eq(noteTypes.userId, userId));
+
+ const noteTypeIdList = userNoteTypeIds.map((nt) => nt.id);
+
+ // Get all note field types for user's note types with syncVersion > lastSyncVersion
+ let pulledNoteFieldTypes: NoteFieldType[] = [];
+ if (noteTypeIdList.length > 0) {
+ const fieldTypeResults = await db
+ .select()
+ .from(noteFieldTypes)
+ .where(gt(noteFieldTypes.syncVersion, lastSyncVersion));
+
+ pulledNoteFieldTypes = fieldTypeResults.filter((ft) =>
+ noteTypeIdList.includes(ft.noteTypeId),
+ );
+ }
+
+ // Get all notes for user's decks with syncVersion > lastSyncVersion
+ let pulledNotes: Note[] = [];
+ if (deckIdList.length > 0) {
+ const noteResults = await db
+ .select()
+ .from(notes)
+ .where(gt(notes.syncVersion, lastSyncVersion));
+
+ pulledNotes = noteResults.filter((n) => deckIdList.includes(n.deckId));
+ }
+
+ // Get note IDs for filtering note field values
+ const noteIdList = pulledNotes.map((n) => n.id);
+
+ // Also get all user's note IDs (not just recently synced ones) for field value filtering
+ let allUserNoteIds: string[] = [];
+ if (deckIdList.length > 0) {
+ const allNotes = await db
+ .select({ id: notes.id })
+ .from(notes)
+ .innerJoin(decks, eq(notes.deckId, decks.id))
+ .where(eq(decks.userId, userId));
+ allUserNoteIds = allNotes.map((n) => n.id);
+ }
+
+ // Get all note field values for user's notes with syncVersion > lastSyncVersion
+ let pulledNoteFieldValues: NoteFieldValue[] = [];
+ if (allUserNoteIds.length > 0) {
+ const fieldValueResults = await db
+ .select()
+ .from(noteFieldValues)
+ .where(gt(noteFieldValues.syncVersion, lastSyncVersion));
+
+ pulledNoteFieldValues = fieldValueResults.filter((fv) =>
+ allUserNoteIds.includes(fv.noteId),
+ );
+ }
+
// Calculate current max sync version across all entities
let currentSyncVersion = lastSyncVersion;
@@ -398,11 +905,35 @@ export const syncRepository: SyncRepository = {
currentSyncVersion = log.syncVersion;
}
}
+ for (const noteType of pulledNoteTypes) {
+ if (noteType.syncVersion > currentSyncVersion) {
+ currentSyncVersion = noteType.syncVersion;
+ }
+ }
+ for (const fieldType of pulledNoteFieldTypes) {
+ if (fieldType.syncVersion > currentSyncVersion) {
+ currentSyncVersion = fieldType.syncVersion;
+ }
+ }
+ for (const note of pulledNotes) {
+ if (note.syncVersion > currentSyncVersion) {
+ currentSyncVersion = note.syncVersion;
+ }
+ }
+ for (const fieldValue of pulledNoteFieldValues) {
+ if (fieldValue.syncVersion > currentSyncVersion) {
+ currentSyncVersion = fieldValue.syncVersion;
+ }
+ }
return {
decks: pulledDecks,
cards: pulledCards,
reviewLogs: pulledReviewLogs,
+ noteTypes: pulledNoteTypes,
+ noteFieldTypes: pulledNoteFieldTypes,
+ notes: pulledNotes,
+ noteFieldValues: pulledNoteFieldValues,
currentSyncVersion,
};
},
diff --git a/src/server/routes/sync.test.ts b/src/server/routes/sync.test.ts
index 7492b49..1107acd 100644
--- a/src/server/routes/sync.test.ts
+++ b/src/server/routes/sync.test.ts
@@ -7,7 +7,15 @@ import type {
SyncPushResult,
SyncRepository,
} from "../repositories/sync.js";
-import type { Card, Deck, ReviewLog } from "../repositories/types.js";
+import type {
+ Card,
+ Deck,
+ Note,
+ NoteFieldType,
+ NoteFieldValue,
+ NoteType,
+ ReviewLog,
+} from "../repositories/types.js";
import { createSyncRouter } from "./sync.js";
function createMockSyncRepo(): SyncRepository {
@@ -35,9 +43,17 @@ interface SyncPushResponse {
decks?: { id: string; syncVersion: number }[];
cards?: { id: string; syncVersion: number }[];
reviewLogs?: { id: string; syncVersion: number }[];
+ noteTypes?: { id: string; syncVersion: number }[];
+ noteFieldTypes?: { id: string; syncVersion: number }[];
+ notes?: { id: string; syncVersion: number }[];
+ noteFieldValues?: { id: string; syncVersion: number }[];
conflicts?: {
decks: string[];
cards: string[];
+ noteTypes: string[];
+ noteFieldTypes: string[];
+ notes: string[];
+ noteFieldValues: string[];
};
error?: {
code: string;
@@ -76,7 +92,18 @@ describe("POST /api/sync/push", () => {
decks: [],
cards: [],
reviewLogs: [],
- conflicts: { decks: [], cards: [] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: {
+ decks: [],
+ cards: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ },
};
vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
@@ -94,11 +121,18 @@ describe("POST /api/sync/push", () => {
expect(body.decks).toEqual([]);
expect(body.cards).toEqual([]);
expect(body.reviewLogs).toEqual([]);
- expect(body.conflicts).toEqual({ decks: [], cards: [] });
+ expect(body.noteTypes).toEqual([]);
+ expect(body.noteFieldTypes).toEqual([]);
+ expect(body.notes).toEqual([]);
+ expect(body.noteFieldValues).toEqual([]);
expect(mockSyncRepo.pushChanges).toHaveBeenCalledWith(userId, {
decks: [],
cards: [],
reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
});
});
@@ -117,7 +151,18 @@ describe("POST /api/sync/push", () => {
decks: [{ id: "deck-uuid-123", syncVersion: 1 }],
cards: [],
reviewLogs: [],
- conflicts: { decks: [], cards: [] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: {
+ decks: [],
+ cards: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ },
};
vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
@@ -141,6 +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,
front: "Question",
back: "Answer",
state: 0,
@@ -161,7 +208,18 @@ describe("POST /api/sync/push", () => {
decks: [],
cards: [{ id: "550e8400-e29b-41d4-a716-446655440001", syncVersion: 1 }],
reviewLogs: [],
- conflicts: { decks: [], cards: [] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: {
+ decks: [],
+ cards: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ },
};
vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
@@ -198,7 +256,18 @@ describe("POST /api/sync/push", () => {
reviewLogs: [
{ id: "550e8400-e29b-41d4-a716-446655440002", syncVersion: 1 },
],
- conflicts: { decks: [], cards: [] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: {
+ decks: [],
+ cards: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ },
};
vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
@@ -238,7 +307,18 @@ describe("POST /api/sync/push", () => {
decks: [{ id: "550e8400-e29b-41d4-a716-446655440003", syncVersion: 5 }],
cards: [],
reviewLogs: [],
- conflicts: { decks: ["550e8400-e29b-41d4-a716-446655440003"], cards: [] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: {
+ decks: ["550e8400-e29b-41d4-a716-446655440003"],
+ cards: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ },
};
vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
@@ -355,6 +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,
front: "Q",
back: "A",
state: 0,
@@ -388,7 +470,18 @@ describe("POST /api/sync/push", () => {
reviewLogs: [
{ id: "550e8400-e29b-41d4-a716-446655440006", syncVersion: 1 },
],
- conflicts: { decks: [], cards: [] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: {
+ decks: [],
+ cards: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ },
};
vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
@@ -412,6 +505,121 @@ describe("POST /api/sync/push", () => {
expect(body.reviewLogs).toHaveLength(1);
});
+ it("successfully pushes note types", async () => {
+ const noteTypeData = {
+ id: "550e8400-e29b-41d4-a716-446655440010",
+ name: "Basic Note",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: false,
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-02T00:00:00.000Z",
+ deletedAt: null,
+ };
+
+ const mockResult: SyncPushResult = {
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ noteTypes: [
+ { id: "550e8400-e29b-41d4-a716-446655440010", syncVersion: 1 },
+ ],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: {
+ decks: [],
+ cards: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ },
+ };
+ vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
+
+ const res = await app.request("/api/sync/push", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ noteTypes: [noteTypeData],
+ }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as SyncPushResponse;
+ expect(body.noteTypes).toHaveLength(1);
+ expect(body.noteTypes?.[0]?.id).toBe(
+ "550e8400-e29b-41d4-a716-446655440010",
+ );
+ });
+
+ it("successfully pushes cards with noteId and isReversed", async () => {
+ const cardData = {
+ id: "550e8400-e29b-41d4-a716-446655440011",
+ deckId: "550e8400-e29b-41d4-a716-446655440000",
+ noteId: "550e8400-e29b-41d4-a716-446655440020",
+ isReversed: true,
+ front: "Question",
+ back: "Answer",
+ state: 0,
+ due: "2024-01-01T00:00:00.000Z",
+ stability: 0,
+ difficulty: 0,
+ elapsedDays: 0,
+ scheduledDays: 0,
+ reps: 0,
+ lapses: 0,
+ lastReview: null,
+ createdAt: "2024-01-01T00:00:00.000Z",
+ updatedAt: "2024-01-02T00:00:00.000Z",
+ deletedAt: null,
+ };
+
+ const mockResult: SyncPushResult = {
+ decks: [],
+ cards: [{ id: "550e8400-e29b-41d4-a716-446655440011", syncVersion: 1 }],
+ reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: {
+ decks: [],
+ cards: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ },
+ };
+ vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
+
+ const res = await app.request("/api/sync/push", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ decks: [],
+ cards: [cardData],
+ reviewLogs: [],
+ }),
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as SyncPushResponse;
+ expect(body.cards).toHaveLength(1);
+ expect(body.cards?.[0]?.id).toBe("550e8400-e29b-41d4-a716-446655440011");
+ });
+
it("handles soft-deleted entities", async () => {
const deletedDeck = {
id: "550e8400-e29b-41d4-a716-446655440007",
@@ -427,7 +635,18 @@ describe("POST /api/sync/push", () => {
decks: [{ id: "550e8400-e29b-41d4-a716-446655440007", syncVersion: 2 }],
cards: [],
reviewLogs: [],
- conflicts: { decks: [], cards: [] },
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ conflicts: {
+ decks: [],
+ cards: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ },
};
vi.mocked(mockSyncRepo.pushChanges).mockResolvedValue(mockResult);
@@ -454,6 +673,10 @@ interface SyncPullResponse {
decks?: Deck[];
cards?: Card[];
reviewLogs?: ReviewLog[];
+ noteTypes?: NoteType[];
+ noteFieldTypes?: NoteFieldType[];
+ notes?: Note[];
+ noteFieldValues?: NoteFieldValue[];
currentSyncVersion?: number;
error?: {
code: string;
@@ -500,6 +723,10 @@ describe("GET /api/sync/pull", () => {
decks: [mockDeck],
cards: [],
reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
currentSyncVersion: 1,
};
vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult);
@@ -515,6 +742,10 @@ describe("GET /api/sync/pull", () => {
expect(body.decks).toHaveLength(1);
expect(body.cards).toHaveLength(0);
expect(body.reviewLogs).toHaveLength(0);
+ expect(body.noteTypes).toHaveLength(0);
+ expect(body.noteFieldTypes).toHaveLength(0);
+ expect(body.notes).toHaveLength(0);
+ expect(body.noteFieldValues).toHaveLength(0);
expect(body.currentSyncVersion).toBe(1);
expect(mockSyncRepo.pullChanges).toHaveBeenCalledWith(userId, {
lastSyncVersion: 0,
@@ -526,6 +757,10 @@ describe("GET /api/sync/pull", () => {
decks: [],
cards: [],
reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
currentSyncVersion: 5,
};
vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult);
@@ -562,6 +797,10 @@ describe("GET /api/sync/pull", () => {
decks: [mockDeck],
cards: [],
reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
currentSyncVersion: 2,
};
vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult);
@@ -607,6 +846,10 @@ describe("GET /api/sync/pull", () => {
decks: [],
cards: [mockCard],
reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
currentSyncVersion: 3,
};
vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult);
@@ -645,6 +888,10 @@ describe("GET /api/sync/pull", () => {
decks: [],
cards: [],
reviewLogs: [mockReviewLog],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
currentSyncVersion: 1,
};
vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult);
@@ -717,6 +964,10 @@ describe("GET /api/sync/pull", () => {
decks: [mockDeck],
cards: [mockCard],
reviewLogs: [mockReviewLog],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
currentSyncVersion: 3,
};
vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult);
@@ -732,9 +983,106 @@ describe("GET /api/sync/pull", () => {
expect(body.decks).toHaveLength(1);
expect(body.cards).toHaveLength(1);
expect(body.reviewLogs).toHaveLength(1);
+ expect(body.noteTypes).toHaveLength(0);
+ expect(body.noteFieldTypes).toHaveLength(0);
+ expect(body.notes).toHaveLength(0);
+ expect(body.noteFieldValues).toHaveLength(0);
expect(body.currentSyncVersion).toBe(3);
});
+ it("returns note types", async () => {
+ const mockNoteType: NoteType = {
+ id: "550e8400-e29b-41d4-a716-446655440010",
+ userId,
+ name: "Basic Note",
+ frontTemplate: "{{Front}}",
+ backTemplate: "{{Back}}",
+ isReversible: false,
+ createdAt: new Date("2024-01-01T00:00:00.000Z"),
+ updatedAt: new Date("2024-01-02T00:00:00.000Z"),
+ deletedAt: null,
+ syncVersion: 1,
+ };
+
+ const mockResult: SyncPullResult = {
+ decks: [],
+ cards: [],
+ reviewLogs: [],
+ noteTypes: [mockNoteType],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ currentSyncVersion: 1,
+ };
+ vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult);
+
+ const res = await app.request("/api/sync/pull", {
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ },
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as SyncPullResponse;
+ expect(body.noteTypes).toHaveLength(1);
+ expect(body.noteTypes?.[0]?.id).toBe(
+ "550e8400-e29b-41d4-a716-446655440010",
+ );
+ expect(body.noteTypes?.[0]?.name).toBe("Basic Note");
+ expect(body.noteTypes?.[0]?.frontTemplate).toBe("{{Front}}");
+ expect(body.noteTypes?.[0]?.isReversible).toBe(false);
+ });
+
+ it("returns cards with noteId and isReversed fields", async () => {
+ const mockCard: Card = {
+ id: "550e8400-e29b-41d4-a716-446655440001",
+ deckId: "550e8400-e29b-41d4-a716-446655440000",
+ noteId: "550e8400-e29b-41d4-a716-446655440020",
+ isReversed: true,
+ front: "Question",
+ back: "Answer",
+ state: 0,
+ due: new Date("2024-01-01T00:00:00.000Z"),
+ stability: 0,
+ difficulty: 0,
+ elapsedDays: 0,
+ scheduledDays: 0,
+ reps: 0,
+ lapses: 0,
+ lastReview: null,
+ createdAt: new Date("2024-01-01T00:00:00.000Z"),
+ updatedAt: new Date("2024-01-02T00:00:00.000Z"),
+ deletedAt: null,
+ syncVersion: 1,
+ };
+
+ const mockResult: SyncPullResult = {
+ decks: [],
+ cards: [mockCard],
+ reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
+ currentSyncVersion: 1,
+ };
+ vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult);
+
+ const res = await app.request("/api/sync/pull", {
+ headers: {
+ Authorization: `Bearer ${authToken}`,
+ },
+ });
+
+ expect(res.status).toBe(200);
+ const body = (await res.json()) as SyncPullResponse;
+ expect(body.cards).toHaveLength(1);
+ expect(body.cards?.[0]?.noteId).toBe(
+ "550e8400-e29b-41d4-a716-446655440020",
+ );
+ expect(body.cards?.[0]?.isReversed).toBe(true);
+ });
+
it("returns soft-deleted entities", async () => {
const deletedDeck: Deck = {
id: "550e8400-e29b-41d4-a716-446655440000",
@@ -752,6 +1100,10 @@ describe("GET /api/sync/pull", () => {
decks: [deletedDeck],
cards: [],
reviewLogs: [],
+ noteTypes: [],
+ noteFieldTypes: [],
+ notes: [],
+ noteFieldValues: [],
currentSyncVersion: 2,
};
vi.mocked(mockSyncRepo.pullChanges).mockResolvedValue(mockResult);
diff --git a/src/server/routes/sync.ts b/src/server/routes/sync.ts
index ff95bf4..f05a7ba 100644
--- a/src/server/routes/sync.ts
+++ b/src/server/routes/sync.ts
@@ -26,6 +26,8 @@ const syncDeckSchema = z.object({
const syncCardSchema = z.object({
id: z.uuid(),
deckId: z.uuid(),
+ noteId: z.uuid().nullable(),
+ isReversed: z.boolean().nullable(),
front: z.string().min(1),
back: z.string().min(1),
state: z.number().int().min(0).max(3),
@@ -53,10 +55,54 @@ const syncReviewLogSchema = z.object({
durationMs: z.number().int().min(0).nullable(),
});
+const syncNoteTypeSchema = z.object({
+ id: z.uuid(),
+ name: z.string().min(1).max(255),
+ frontTemplate: z.string(),
+ backTemplate: z.string(),
+ isReversible: z.boolean(),
+ createdAt: z.string().datetime(),
+ updatedAt: z.string().datetime(),
+ deletedAt: z.string().datetime().nullable(),
+});
+
+const syncNoteFieldTypeSchema = z.object({
+ id: z.uuid(),
+ noteTypeId: z.uuid(),
+ name: z.string().min(1).max(255),
+ order: z.number().int().min(0),
+ fieldType: z.string(),
+ createdAt: z.string().datetime(),
+ updatedAt: z.string().datetime(),
+ deletedAt: z.string().datetime().nullable(),
+});
+
+const syncNoteSchema = z.object({
+ id: z.uuid(),
+ deckId: z.uuid(),
+ noteTypeId: z.uuid(),
+ createdAt: z.string().datetime(),
+ updatedAt: z.string().datetime(),
+ deletedAt: z.string().datetime().nullable(),
+});
+
+const syncNoteFieldValueSchema = z.object({
+ id: z.uuid(),
+ noteId: z.uuid(),
+ noteFieldTypeId: z.uuid(),
+ value: z.string(),
+ createdAt: z.string().datetime(),
+ updatedAt: z.string().datetime(),
+});
+
const syncPushSchema = z.object({
decks: z.array(syncDeckSchema).default([]),
cards: z.array(syncCardSchema).default([]),
reviewLogs: z.array(syncReviewLogSchema).default([]),
+ noteTypes: z.array(syncNoteTypeSchema).default([]),
+ noteFieldTypes: z.array(syncNoteFieldTypeSchema).default([]),
+ notes: z.array(syncNoteSchema).default([]),
+ noteFieldValues: z.array(syncNoteFieldValueSchema).default([]),
});
const syncPullQuerySchema = z.object({