aboutsummaryrefslogtreecommitdiffhomepage
path: root/src/server/repositories
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/repositories')
-rw-r--r--src/server/repositories/sync.ts535
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,
};
},