forked from mirror/crumbs-original
Address review feedback on the concurrent-edit merge change: - When no base snapshot is available (e.g. a metadata-only change bumped the version without creating a snapshot, or the base was pruned), mergeContentUpdate now keeps the incoming edit instead of discarding it in favour of the server copy, and logs a warning. Dropping it silently lost the user's offline edits. - Unify getBaseContent into one shared helper in versions-service and drop sync's "fall back to the latest snapshot" branch: a base newer than what the client actually saw made the 3-way merge treat other users' lines as deletions and silently remove them. - Remove the now-dead mergeContentTwoWay helper. - Add coverage: null-base keeps incoming (unit), a metadata-bump concurrent edit is preserved (integration), and getBaseContent returns null rather than a newer snapshot. Verified the null-base cases fail against the old drop-incoming behavior. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
147 lines
3.7 KiB
TypeScript
147 lines
3.7 KiB
TypeScript
import type { Db } from './db/index.js';
|
|
import { noteVersions, notes } from './db/schema.js';
|
|
import { eq, and, desc, lte } from 'drizzle-orm';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import type { NoteVersion, NoteVersionSummary, NoteColor } from '$lib/types/index.js';
|
|
|
|
const MAX_VERSIONS_PER_NOTE = 50;
|
|
const CONTENT_PREVIEW_LENGTH = 80;
|
|
|
|
export type Transaction = Parameters<Parameters<Db['transaction']>[0]>[0];
|
|
export type SnapshotDb = Db | Transaction;
|
|
|
|
/**
|
|
* Resolve the 3-way merge base for a concurrent edit: the content of the snapshot
|
|
* at or before the client's base version. Returns null when the base version is
|
|
* unknown or no snapshot at/before it exists. Callers must NOT substitute a newer
|
|
* snapshot as the base — a base newer than what the client actually saw makes the
|
|
* merge treat other users' lines as deletions and silently drop them.
|
|
*/
|
|
export function getBaseContent(
|
|
db: SnapshotDb,
|
|
noteId: string,
|
|
baseVersion: number | undefined
|
|
): string | null {
|
|
if (baseVersion === undefined) return null;
|
|
|
|
const snapshot = db
|
|
.select({ content: noteVersions.content })
|
|
.from(noteVersions)
|
|
.where(and(eq(noteVersions.noteId, noteId), lte(noteVersions.version, baseVersion)))
|
|
.orderBy(desc(noteVersions.version))
|
|
.limit(1)
|
|
.get();
|
|
|
|
return snapshot?.content ?? null;
|
|
}
|
|
|
|
/**
|
|
* Create a snapshot of a note's current state.
|
|
* Only called when content actually changed. Prunes snapshots beyond MAX_VERSIONS_PER_NOTE.
|
|
*/
|
|
export function createSnapshot(
|
|
db: SnapshotDb,
|
|
noteId: string,
|
|
snapshot: {
|
|
version: number;
|
|
title: string;
|
|
content: string;
|
|
checklistMode: boolean;
|
|
color: string;
|
|
}
|
|
): void {
|
|
const now = new Date();
|
|
|
|
db.insert(noteVersions)
|
|
.values({
|
|
id: uuidv4(),
|
|
noteId,
|
|
version: snapshot.version,
|
|
title: snapshot.title,
|
|
content: snapshot.content,
|
|
checklistMode: snapshot.checklistMode,
|
|
color: snapshot.color,
|
|
createdAt: now
|
|
})
|
|
.onConflictDoNothing()
|
|
.run();
|
|
|
|
// Prune: keep only the most recent MAX_VERSIONS_PER_NOTE snapshots
|
|
const allVersions = db
|
|
.select({ id: noteVersions.id })
|
|
.from(noteVersions)
|
|
.where(eq(noteVersions.noteId, noteId))
|
|
.orderBy(desc(noteVersions.version))
|
|
.all();
|
|
|
|
if (allVersions.length > MAX_VERSIONS_PER_NOTE) {
|
|
const toDelete = allVersions.slice(MAX_VERSIONS_PER_NOTE).map((v) => v.id);
|
|
for (const id of toDelete) {
|
|
db.delete(noteVersions).where(eq(noteVersions.id, id)).run();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List version summaries for a note, newest first.
|
|
*/
|
|
export function listVersions(db: Db, noteId: string): NoteVersionSummary[] {
|
|
const rows = db
|
|
.select()
|
|
.from(noteVersions)
|
|
.where(eq(noteVersions.noteId, noteId))
|
|
.orderBy(desc(noteVersions.version))
|
|
.all();
|
|
|
|
return rows.map((row) => ({
|
|
id: row.id,
|
|
noteId: row.noteId,
|
|
version: row.version,
|
|
title: row.title,
|
|
contentPreview: row.content.slice(0, CONTENT_PREVIEW_LENGTH),
|
|
createdAt: row.createdAt
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Get the full content of a specific version.
|
|
*/
|
|
export function getVersion(db: Db, noteId: string, versionId: string): NoteVersion | null {
|
|
const row = db
|
|
.select()
|
|
.from(noteVersions)
|
|
.where(and(eq(noteVersions.id, versionId), eq(noteVersions.noteId, noteId)))
|
|
.get();
|
|
|
|
if (!row) return null;
|
|
|
|
return {
|
|
id: row.id,
|
|
noteId: row.noteId,
|
|
version: row.version,
|
|
title: row.title,
|
|
content: row.content,
|
|
checklistMode: row.checklistMode,
|
|
color: row.color as NoteColor,
|
|
createdAt: row.createdAt
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Snapshot the current state of a note (used before a restore).
|
|
*/
|
|
export function snapshotCurrentNote(db: Db, noteId: string): boolean {
|
|
const currentNote = db.select().from(notes).where(eq(notes.id, noteId)).get();
|
|
if (!currentNote) return false;
|
|
|
|
createSnapshot(db, noteId, {
|
|
version: currentNote.version,
|
|
title: currentNote.title,
|
|
content: currentNote.content,
|
|
checklistMode: currentNote.checklistMode,
|
|
color: currentNote.color
|
|
});
|
|
|
|
return true;
|
|
}
|