From 2f8d3c24a47a0aa11d5aac7f5508f065e0fc41c8 Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Mon, 29 Jun 2026 00:43:56 +0200 Subject: [PATCH] feat(notes): keep old shas as immutable snapshots Cache content under its own sha (write-once) with the path key as the latest pointer, and advance the stack handle to the new sha after an edit or pull. A shared link keeps showing exactly what was shared, while the live view follows your changes. --- src/components/StackedNote.vue | 12 +++- src/hooks/useRouteQueryStackedNotes.hook.ts | 16 +++++ .../note/cache/prepareNoteCache.spec.ts | 65 +++++++++++++++++++ src/modules/note/cache/prepareNoteCache.ts | 8 ++- 4 files changed, 99 insertions(+), 2 deletions(-) create mode 100644 src/modules/note/cache/prepareNoteCache.spec.ts diff --git a/src/components/StackedNote.vue b/src/components/StackedNote.vue index 7ae3362..5948415 100644 --- a/src/components/StackedNote.vue +++ b/src/components/StackedNote.vue @@ -57,7 +57,14 @@ const repo = computed(() => props.repo) const sha = computed(() => props.sha) const index = computed(() => props.index) -const { scrollToFocusedNote } = useRouteQueryStackedNotes() +const { scrollToFocusedNote, replaceStackedNote } = useRouteQueryStackedNotes() + +// When this note's content changes (edit / pull) its sha changes too; advance +// the stack handle so the live view follows it, leaving the old sha as an +// immutable snapshot for any link already shared. +const advanceStackTo = (newSha: string | null) => { + if (newSha) replaceStackedNote(sha.value, newSha) +} const { path, @@ -224,6 +231,7 @@ const performSave = async (overrideSha?: string) => { path: path.value }) initialRawContent.value = rawContent.value + advanceStackTo(newSha) } watch(mode, async (newMode) => { @@ -257,6 +265,7 @@ const onConflictDiscard = async () => { if (raw !== null) { rawContent.value = raw initialRawContent.value = raw + advanceStackTo(latestSha.value) } } @@ -290,6 +299,7 @@ const onBadgeClick = async () => { if (raw !== null) { rawContent.value = raw initialRawContent.value = raw + advanceStackTo(latestSha.value) return } if (failureStatus === "unauthorized") { diff --git a/src/hooks/useRouteQueryStackedNotes.hook.ts b/src/hooks/useRouteQueryStackedNotes.hook.ts index c79cb95..1bd8d18 100644 --- a/src/hooks/useRouteQueryStackedNotes.hook.ts +++ b/src/hooks/useRouteQueryStackedNotes.hook.ts @@ -149,9 +149,25 @@ export const useRouteQueryStackedNotes = () => { scrollToFocusedNote({ noteId: selector ?? sha, hash, anchorTop }) } + // Advance a note's handle in place when its content changes (edit / pull): + // the live view follows to the new sha, while any previously-shared link + // keeps pointing at the old, now-immutable snapshot. + const replaceStackedNote = (oldSha: string, newSha: string) => { + if (!oldSha || !newSha || oldSha === newSha) { + return + } + if (!stackedNotes.value.includes(oldSha)) { + return + } + stackedNotes.value = stackedNotes.value.map((note) => + note === oldSha ? newSha : note + ) + } + return { stackedNotes: readonly(stackedNotes), addStackedNote, + replaceStackedNote, scrollToFocusedNote } } diff --git a/src/modules/note/cache/prepareNoteCache.spec.ts b/src/modules/note/cache/prepareNoteCache.spec.ts new file mode 100644 index 0000000..b845228 --- /dev/null +++ b/src/modules/note/cache/prepareNoteCache.spec.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from "vitest" + +vi.mock("@/data/data", () => ({ + data: { + get: vi.fn().mockResolvedValue(null), + update: vi.fn().mockResolvedValue(undefined) + }, + generateId: (type: string, id: string) => `${type}-${id}` +})) + +const addFile = vi.fn() +vi.mock("@/modules/repo/store/userRepo.store", () => ({ + useUserRepoStore: () => ({ addFile }) +})) + +import { data, generateId } from "@/data/data" +import { DataType } from "@/data/DataType.enum" + +import { prepareNoteCache } from "./prepareNoteCache" + +const writtenIds = () => + vi.mocked(data.update).mock.calls.map((c) => (c[0] as { _id: string })._id) + +describe("prepareNoteCache.saveCacheNote", () => { + beforeEach(() => { + vi.mocked(data.update).mockClear() + addFile.mockClear() + }) + + it("on edit, keys content by the NEW sha and leaves the viewed sha untouched", async () => { + const { saveCacheNote } = prepareNoteCache("oldSha", "notes/a.md") + + await saveCacheNote("new content", { + editedSha: "newSha", + path: "notes/a.md" + }) + + const ids = writtenIds() + // immutable snapshot under the content's own (new) sha + expect(ids).toContain(generateId(DataType.Note, "newSha")) + // latest pointer under the path + expect(ids).toContain(generateId(DataType.Note, "notes/a.md")) + // the previously-viewed sha stays immutable + expect(ids).not.toContain(generateId(DataType.Note, "oldSha")) + }) + + it("on a fresh load (no editedSha), keys content by the viewed sha", async () => { + const { saveCacheNote } = prepareNoteCache("sha0", "notes/a.md") + + await saveCacheNote("content") + + expect(writtenIds()).toContain(generateId(DataType.Note, "sha0")) + }) + + it("registers the new sha against the path in the store", async () => { + const { saveCacheNote } = prepareNoteCache("oldSha", "notes/a.md") + + await saveCacheNote("new content", { + editedSha: "newSha", + path: "notes/a.md" + }) + + expect(addFile).toHaveBeenCalledWith({ path: "notes/a.md", sha: "newSha" }) + }) +}) diff --git a/src/modules/note/cache/prepareNoteCache.ts b/src/modules/note/cache/prepareNoteCache.ts index 65d5b32..59482b9 100644 --- a/src/modules/note/cache/prepareNoteCache.ts +++ b/src/modules/note/cache/prepareNoteCache.ts @@ -61,8 +61,14 @@ export const prepareNoteCache = (sha: string, path?: string) => { content: string, params?: { editedSha?: string; path?: string } ) => { + // Content is addressed by its OWN sha so snapshots stay immutable: an edit + // writes under the new sha and never overwrites the previously-viewed one. + // The path key (notePath) always holds the latest content (live pointer). + const contentId = params?.editedSha + ? generateId(DataType.Note, params.editedSha) + : noteId const newNote: Note = { - _id: noteId, + _id: contentId, $type: DataType.Note, content, editedSha: params?.editedSha