diff options
Diffstat (limited to 'src/server/repositories/sync.ts')
| -rw-r--r-- | src/server/repositories/sync.ts | 535 |
1 files changed, 533 insertions, 2 deletions
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, }; }, |
