Compare commits

...
4 changed files with 480 additions and 10 deletions

No files matched your search

+52 -10
View File
@@ -80,6 +80,7 @@ import {
registerNotesActions,
} from "./notesStore";
import { commitVaultNotesWrite } from "./vaultNotesPersistence";
import { rebasePendingVaultNotes } from "../../domain/vaultNotesReconciliation";
import { publishShellHistorySnapshot } from "./shellHistoryStore";
import { setVaultInitialized } from "./vaultInitStore";
import { notify } from "../notification";
@@ -306,6 +307,12 @@ export const useVaultState = () => {
const snippetsRef = useRef<Snippet[]>([]);
const groupConfigsRef = useRef<GroupConfig[]>([]);
const notesRef = useRef<VaultNote[]>([]);
const notesStorageRef = useRef<VaultNote[]>([]);
// An optimistic React echo is not a persistence acknowledgement. Keep this
// owner until that exact generation has successfully reached storage.
const notesPendingRef = useRef<VaultNote[] | null>(null);
// Failed clear/import keeps full-snapshot ownership across later local edits.
const notesPendingReplaceRef = useRef(false);
const noteGroupsRef = useRef<string[]>([]);
const notesPersistFailureNotifiedAtRef = useRef(0);
customGroupsRef.current = customGroups;
@@ -834,17 +841,37 @@ export const useVaultState = () => {
localStorageAdapter.write(STORAGE_KEY_SNIPPET_PACKAGES, data);
}, []);
const updateNotes = useCallback((data: Partial<VaultNote>[]) => {
const updateNotes = useCallback((data: Partial<VaultNote>[], options?: { replace?: boolean }) => {
const stored = normalizeVaultNotes(localStorageAdapter.read<VaultNote[]>(STORAGE_KEY_NOTES) ?? []);
const requested = normalizeVaultNotes(data);
// Explicit clear/restore keeps its existing full-snapshot semantics.
const replace = options?.replace === true || notesPendingReplaceRef.current;
const pending = replace ? requested : rebasePendingVaultNotes({
base: notesStorageRef.current,
ours: requested,
theirs: stored,
});
notesStorageRef.current = stored;
notesPendingRef.current = pending;
notesPendingReplaceRef.current = replace;
notesRef.current = pending;
const { notes: cleaned, persisted } = commitVaultNotesWrite({
data,
data: pending,
write: (key, value) => localStorageAdapter.write(key, value),
});
// Keep the in-session catalog updated so an autosave quota failure does not
// snap the editor back to stale props and discard the user's draft. Disk
// may still be behind — surface that explicitly.
notesRef.current = cleaned;
setNotes(cleaned);
publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current });
if (notesPendingRef.current === pending) {
notesRef.current = cleaned;
if (persisted) {
notesStorageRef.current = cleaned;
notesPendingRef.current = null;
notesPendingReplaceRef.current = false;
}
setNotes(cleaned);
publishNotesSnapshot({ notes: cleaned, noteGroups: noteGroupsRef.current });
}
if (!persisted) {
const now = Date.now();
// Debounced autosave can hit quota repeatedly; avoid toast spam.
@@ -1163,7 +1190,7 @@ export const useVaultState = () => {
updateProxyProfiles([]);
updateSnippets([], { replace: true });
updateSnippetPackages([]);
updateNotes([]);
updateNotes([], { replace: true });
updateNoteGroups([]);
updateCustomGroups([]);
updateKnownHosts([]);
@@ -1534,8 +1561,10 @@ export const useVaultState = () => {
if (savedGroups) setCustomGroups(savedGroups);
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
if (savedNotes) {
if (savedNotes && notesPendingRef.current === null) {
const cleanedNotes = normalizeVaultNotes(savedNotes);
notesStorageRef.current = cleanedNotes;
notesRef.current = cleanedNotes;
setNotes(cleanedNotes);
localStorageAdapter.write(STORAGE_KEY_NOTES, cleanedNotes);
}
@@ -1780,8 +1809,21 @@ export const useVaultState = () => {
}
if (key === STORAGE_KEY_NOTES) {
const next = safeParse<VaultNote[]>(event.newValue) ?? [];
setNotes(normalizeVaultNotes(next));
// Like snippets, ignore queued events for a snapshot no longer on disk.
if (event.newValue !== localStorageAdapter.readString(STORAGE_KEY_NOTES)) return;
// Only a successful current write can release an outstanding replacement.
if (notesPendingReplaceRef.current) return;
const next = normalizeVaultNotes(safeParse<VaultNote[]>(event.newValue) ?? []);
const pending = notesPendingRef.current;
const visible = pending === null ? next : rebasePendingVaultNotes({
base: notesStorageRef.current,
ours: pending,
theirs: next,
});
notesStorageRef.current = next;
if (pending !== null) notesPendingRef.current = visible;
notesRef.current = visible;
setNotes(visible);
return;
}
@@ -1952,7 +1994,7 @@ export const useVaultState = () => {
}
if (payload.customGroups) updateCustomGroups(payload.customGroups);
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
if (payload.notes) updateNotes(payload.notes);
if (payload.notes) updateNotes(payload.notes, { replace: true });
if (payload.noteGroups) updateNoteGroups(payload.noteGroups);
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
if (Array.isArray(payload.groupConfigs)) encryptedWrites.push(updateGroupConfigs(payload.groupConfigs));
@@ -0,0 +1,336 @@
import assert from "node:assert/strict";
import test from "node:test";
import { register } from "node:module";
import React, { act } from "react";
import type { VaultNote } from "../../domain/models.ts";
// JSDOM has no stylesheet loader. All editor/application JavaScript stays real.
register(`data:text/javascript,${encodeURIComponent(`
export async function load(url, context, nextLoad) {
if (url.endsWith("/components/notes/noteMath.scss")) {
return { format: "module", source: "export {};", shortCircuit: true };
}
return nextLoad(url, context);
}
`)}`, import.meta.url);
// Same browser shims as InlineMarkdownEditor.unrenderableMarkdown.test.tsx.
import {JSDOM} from "jsdom";
const setupDom = () => {
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', {
pretendToBeVisual: true,
url: "http://localhost",
});
const window = dom.window;
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
const installGlobal = (key: string, value: unknown) => {
previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
};
class ResizeObserverStub {
observe() {}
unobserve() {}
disconnect() {}
}
function LoadedImageStub() {
const image = window.document.createElement("img");
Object.defineProperty(image, "src", {
get: () => image.getAttribute("src") ?? "",
set: (src: string) => {
image.setAttribute("src", src);
queueMicrotask(() => image.dispatchEvent(new window.Event("load")));
},
});
return image;
}
Object.assign(window.Range.prototype, {
getClientRects: () => [],
getBoundingClientRect: () => new window.DOMRect(),
});
// CodeMirror needs these browser APIs when the full note contains code blocks.
window.matchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener() {},
removeListener() {},
addEventListener() {},
removeEventListener() {},
dispatchEvent: () => false,
});
for (const [key, value] of Object.entries({
window,
Window: window.Window,
Image: LoadedImageStub,
document: window.document,
navigator: window.navigator,
HTMLElement: window.HTMLElement,
HTMLImageElement: window.HTMLImageElement,
HTMLInputElement: window.HTMLInputElement,
HTMLTextAreaElement: window.HTMLTextAreaElement,
HTMLSelectElement: window.HTMLSelectElement,
Element: window.Element,
SVGElement: window.SVGElement,
Node: window.Node,
DocumentFragment: window.DocumentFragment,
Range: window.Range,
NodeFilter: window.NodeFilter,
MutationObserver: window.MutationObserver,
CustomEvent: window.CustomEvent,
DOMRect: window.DOMRect,
Event: window.Event,
KeyboardEvent: window.KeyboardEvent,
MouseEvent: window.MouseEvent,
getComputedStyle: window.getComputedStyle.bind(window),
requestAnimationFrame: window.requestAnimationFrame.bind(window),
cancelAnimationFrame: window.cancelAnimationFrame.bind(window),
ResizeObserver: ResizeObserverStub,
IS_REACT_ACT_ENVIRONMENT: true,
})) {
installGlobal(key, value);
}
return {
window,
cleanup() {
for (const [key, descriptor] of previousGlobals) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else delete (globalThis as Record<string, unknown>)[key];
}
dom.window.close();
},
};
};
for (const mode of ["source", "edit"] as const) {
test(`mounted notes ${mode}: failed save retains both versions through retry and switching`, { timeout: 30_000 }, async (t) => {
const { window, cleanup } = setupDom();
// The app initializes unrelated vault collections under Web Locks. Serialize
// those calls in the fixture too (Node 22 does not always expose Web Locks).
const tails = new Map<string, Promise<unknown>>();
Object.defineProperty(window.navigator, "locks", { value: {
request(name: string, callback: () => unknown) {
const run = (tails.get(name) ?? Promise.resolve()).then(callback, callback);
tails.set(name, run.then(() => undefined, () => undefined));
return run;
},
} });
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: window.localStorage });
const keys = await import("../../infrastructure/config/storageKeys.ts");
for (const [name, key] of Object.entries(keys)) {
if (name.startsWith("STORAGE_KEY_") && typeof key === "string") window.localStorage.setItem(key, "[]");
}
const original: VaultNote = { id: "synthetic-note", title: "Synthetic note", content: "Persisted baseline", createdAt: 1, updatedAt: 1, order: 0 };
const second: VaultNote = { ...original, id: "second-note", title: "Second note", content: "Second body", order: 1 };
window.localStorage.setItem(keys.STORAGE_KEY_NOTES, JSON.stringify([original, second]));
window.localStorage.setItem(keys.STORAGE_KEY_VAULT_NOTES_EDITOR_MODE, mode);
const { useVaultState } = await import("../../application/state/useVaultState.ts");
const { getNotesSnapshot, subscribeNotes } = await import("../../application/state/notesStore.ts");
const { setNotify } = await import("../../application/notification.ts");
const { I18nProvider } = await import("../../application/i18n/I18nProvider.tsx");
const { TooltipProvider } = await import("../ui/tooltip.tsx");
const { NotesManager } = await import("./NotesManager.tsx");
const { createRoot } = await import("react-dom/client");
const rootNode = window.document.getElementById("root")!;
const root = createRoot(rootNode);
let vault!: ReturnType<typeof useVaultState>;
let openNote!: React.Dispatch<React.SetStateAction<string>>;
const saveResults: boolean[] = [];
const notices: string[] = [];
let quota = false;
const realSetItem = window.Storage.prototype.setItem;
window.Storage.prototype.setItem = function (key: string, value: string) {
if (quota && key === keys.STORAGE_KEY_NOTES) throw new DOMException("Synthetic quota exceeded", "QuotaExceededError");
return realSetItem.call(this, key, value);
};
setNotify({ error: message => notices.push(message), info: () => {}, warning: () => {}, success: () => {} });
function App() {
const state = useVaultState();
vault = state;
const [noteId, setNoteId] = React.useState(original.id);
openNote = setNoteId;
const updateNotes = state.updateNotes;
const save = React.useCallback((notes: VaultNote[]) => {
const result = updateNotes(notes);
saveResults.push(result);
return result;
}, [updateNotes]);
return <I18nProvider locale="en"><TooltipProvider><NotesManager notes={state.notes} noteGroups={state.noteGroups}
hosts={[]} onUpdateNotes={save} onUpdateNoteGroups={state.updateNoteGroups} displayMode="full" openNoteId={noteId} />
</TooltipProvider></I18nProvider>;
}
const editor = () => mode === "source" ? rootNode.querySelector<HTMLTextAreaElement>("textarea") : rootNode.querySelector<HTMLElement>('[contenteditable="true"]');
const visible = () => (mode === "source" ? (editor() as HTMLTextAreaElement)?.value : editor()?.textContent)?.trim();
const waitFor = async (check: () => boolean, label: string) => {
const deadline = Date.now() + 6000;
while (!check()) {
assert.ok(Date.now() < deadline, `${label}; saves=${JSON.stringify(saveResults)}, visible=${visible()}, notes=${JSON.stringify(vault.notes)}`);
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
}
};
const persisted = (): VaultNote[] => JSON.parse(window.localStorage.getItem(keys.STORAGE_KEY_NOTES)!);
const primary = () => vault.notes.find(note => note.id === original.id)!;
const edit = async (content: string) => {
await act(async () => {
if (mode === "source") {
const textarea = editor() as HTMLTextAreaElement;
Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, "value")!.set!.call(textarea, content);
textarea.dispatchEvent(new window.Event("input", { bubbles: true }));
} else {
const lexical = await import("lexical");
const instance = lexical.getNearestEditorFromDOMNode(editor()!);
assert.ok(instance);
instance.update(() => { lexical.$getRoot().clear().append(lexical.$createParagraphNode().append(lexical.$createTextNode(content))); }, { discrete: true });
}
});
await waitFor(() => visible() === content, "local edit is visible");
};
const incoming = async (notes: VaultNote[]) => {
const value = JSON.stringify(notes);
// Independent writer bypasses only our synthetic quota failure.
realSetItem.call(window.localStorage, keys.STORAGE_KEY_NOTES, value);
await act(async () => { window.dispatchEvent(new window.StorageEvent("storage", { key: keys.STORAGE_KEY_NOTES, newValue: value, storageArea: window.localStorage })); });
};
try {
await act(async () => root.render(<App />));
await waitFor(() => vault.isInitialized && visible() === original.content, "initial editor");
await edit("Successfully saved");
await waitFor(() => saveResults.length === 1, "normal successful debounce");
assert.equal(saveResults[0], true);
assert.equal(persisted()[0].content.trim(), "Successfully saved");
await incoming([{ ...original, content: "Legitimate external update", updatedAt: 5 }, second]);
await waitFor(() => visible() === "Legitimate external update", "external update after successful save");
quota = true;
const local = "Unsaved local draft";
await edit(local);
assert.equal(saveResults.length, 1, "dirty control precedes autosave echo");
await incoming([{ ...original, content: "Remote before echo", updatedAt: 10 }, second]);
assert.equal(primary().content, "Remote before echo");
assert.equal(visible(), local, "existing editor dirty guard works before echo");
await waitFor(() => saveResults.length === 2 && primary().content.trim() === local, "failed save optimistic echo");
assert.equal(saveResults[1], false);
assert.equal(notices.length, 1);
assert.equal(persisted()[0].content, "Remote before echo");
assert.ok(primary().updatedAt > 11, "incoming timestamps deliberately run behind local time");
const remote = [{ ...original, content: "Remote after echo", updatedAt: 11 }, second];
await incoming(remote);
assert.equal(visible(), local, "failed optimistic echo must not acknowledge durability");
assert.equal(primary().content.trim(), local);
assert.equal(getNotesSnapshot().notes.find(note => note.id === original.id)!.content.trim(), local);
const copy = vault.notes.find(note => note.id !== original.id && note.content === "Remote after echo");
assert.ok(copy, "external conflicting content must remain accessible as a note");
const copyId = copy.id;
await incoming(remote);
assert.equal(vault.notes.length, 3, "repeated storage delivery creates no duplicate copy");
await edit("Unsaved local draft revision two");
await waitFor(() => saveResults.length === 3, "second failed debounce");
assert.equal(saveResults[2], false);
assert.equal(notices.length, 1, "repeated failure respects notification throttling");
await act(async () => openNote(second.id));
await waitFor(() => visible() === second.content, "switch to second note");
await act(async () => openNote(original.id));
await waitFor(() => visible() === "Unsaved local draft revision two", "switch back retains failed draft");
// Switching notes intentionally defers the content swap by two frames.
await act(async () => { await new Promise(resolve => window.requestAnimationFrame(() => window.requestAnimationFrame(resolve))); });
assert.equal(vault.notes.find(note => note.id === copyId)?.content, "Remote after echo");
quota = false;
await edit("Durable recovered draft");
await waitFor(() => saveResults.length === 4, "successful retry");
assert.equal(saveResults[3], true);
assert.equal(persisted().find(note => note.id === original.id)?.content.trim(), "Durable recovered draft");
assert.equal(persisted().find(note => note.id === copyId)?.content, "Remote after echo");
const afterSuccess = persisted().map(note => note.id === original.id ? { ...note, content: "External after recovery", updatedAt: 2 } : note);
await incoming(afterSuccess);
await waitFor(() => visible() === "External after recovery", "successful same-generation save releases pending protection");
assert.equal(vault.notes.length, 3);
await act(async () => { window.dispatchEvent(new window.StorageEvent("storage", {
key: keys.STORAGE_KEY_NOTES, newValue: JSON.stringify(remote), storageArea: window.localStorage,
})); });
assert.equal(visible(), "External after recovery", "queued obsolete storage events must not roll back a successful save");
// Publishing a successful generation can synchronously trigger another
// write. The older acknowledgement must never clear the newer failure.
let nestedResult: boolean | undefined;
let triggered = false;
const unsubscribe = subscribeNotes(() => {
if (triggered) return;
triggered = true;
quota = true;
nestedResult = vault.updateNotes(getNotesSnapshot().notes.map(note => note.id === original.id ? { ...note, content: "Newer failed generation" } : note));
});
try {
await act(async () => {
assert.equal(vault.updateNotes(vault.notes.map(note => note.id === original.id ? { ...note, content: "Older successful generation" } : note)), true);
});
} finally { unsubscribe(); }
assert.equal(nestedResult, false);
await incoming(persisted().map(note => note.id === original.id ? { ...note, content: "Concurrent with newer failure" } : note));
assert.equal(primary().content, "Newer failed generation");
assert.ok(vault.notes.some(note => note.content === "Concurrent with newer failure"));
// Full-snapshot replacement (clear/restore) remains explicit and does not
// accidentally resurrect a disk-only note through the save-time rebase.
quota = false;
realSetItem.call(window.localStorage, keys.STORAGE_KEY_NOTES, JSON.stringify([...persisted(), { ...second, id: "disk-only" }]));
await act(async () => { assert.equal(vault.updateNotes([original], { replace: true }), true); });
assert.deepEqual(persisted().map(note => note.id), [original.id]);
for (const operation of ["clear", "import"] as const) {
await t.test(`failed ${operation} retains replacement ownership through events and retry`, async () => {
quota = false;
await act(async () => { assert.equal(vault.updateNotes([original, second], { replace: true }), true); });
const replacement = operation === "clear" ? [] : [{ ...original, content: "Imported snapshot" }];
quota = true;
await act(async () => {
if (operation === "clear") vault.clearVaultData();
else await vault.importDataFromString(JSON.stringify({ notes: replacement }));
});
const contents = (notes: VaultNote[]) => notes.map(({ id, content }) => [id, content]);
assert.deepEqual(contents(vault.notes), contents(replacement));
assert.deepEqual(contents(persisted()), contents([original, second]), "replacement has not reached disk");
const peer = [
{ ...original, content: "Changed in another window", updatedAt: 30 }, second,
{ ...second, id: "peer-added", content: "Added in another window" },
];
await incoming(peer);
assert.deepEqual(contents(vault.notes), contents(replacement), "failed replacement must not adopt peer edits or additions");
assert.deepEqual(contents(getNotesSnapshot().notes), contents(replacement));
const edited = operation === "clear"
? [{ ...original, id: "created-after-clear", content: "New local note" }]
: replacement.map(note => ({ ...note, content: "Edited after import" }));
await act(async () => { assert.equal(vault.updateNotes(edited), false); });
await incoming([...peer, { ...second, id: "peer-added-again" }]);
assert.deepEqual(contents(vault.notes), contents(edited), "an ordinary failed edit must inherit replacement ownership");
// Also cover a peer write whose storage event has not arrived yet.
realSetItem.call(window.localStorage, keys.STORAGE_KEY_NOTES, JSON.stringify([...peer, { ...second, id: "unobserved-peer" }]));
quota = false;
await act(async () => { assert.equal(vault.updateNotes(vault.notes), true); });
assert.deepEqual(contents(persisted()), contents(edited), "successful retry persists only the replacement catalog");
const afterReplacement = [{ ...second, id: "legitimate-later-peer" }];
await incoming(afterReplacement);
assert.deepEqual(contents(vault.notes), contents(afterReplacement), "successful current owner releases replacement protection");
});
}
console.log(`NOTES_FIX_${mode.toUpperCase()}: success, dirty-before-echo, failed-echo conflict, duplicate delivery, repeated failure, switch, retry, legal external update passed`);
} finally {
await act(async () => root.unmount());
window.Storage.prototype.setItem = realSetItem;
setNotify({ error: () => {}, info: () => {}, warning: () => {}, success: () => {} });
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
else delete (globalThis as Record<string, unknown>).localStorage;
cleanup();
}
});
}
+52
View File
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { VaultNote } from "./models.ts";
import { normalizeVaultNotes } from "./notes.ts";
import { rebasePendingVaultNotes } from "./vaultNotesReconciliation.ts";
const note = (id: string, content: string, extra: Partial<VaultNote> = {}) =>
normalizeVaultNotes([{ id, title: id, content, createdAt: 1, updatedAt: 1, order: 0, ...extra }])[0];
test("pending notes retain both conflicting versions without choosing by timestamp", () => {
const base = [note("a", "base")];
const ours = [note("a", "local", { updatedAt: 1000 })];
const theirs = [note("a", "remote", { updatedAt: 2, tags: ["external"], group: "folder" })];
const merged = rebasePendingVaultNotes({ base, ours, theirs });
assert.deepEqual(merged[0], ours[0]);
assert.equal(merged.length, 2);
assert.notEqual(merged[1].id, "a");
assert.equal(merged[1].title, "a (conflict copy)");
assert.equal(merged[1].content, "remote");
assert.deepEqual(merged[1].tags, ["external"]);
assert.equal(merged[1].group, "folder");
assert.equal(merged[1].updatedAt, 2);
assert.deepEqual(rebasePendingVaultNotes({ base: theirs, ours: merged, theirs }), merged,
"repeated delivery keeps the same copy ID");
});
test("pending note edits preserve unrelated external updates, additions and deletions", () => {
const base = [note("a", "base"), note("b", "base"), note("c", "base")];
const ours = [note("a", "local"), base[1], base[2], note("d", "local addition")];
const theirs = [base[0], note("b", "remote"), note("e", "remote addition")];
const result = rebasePendingVaultNotes({ base, ours, theirs });
assert.deepEqual(result.map(({ id, content }) => [id, content]), [
["a", "local"], ["b", "remote"], ["d", "local addition"], ["e", "remote addition"],
]);
});
test("conflicting delete/edit keeps the surviving content; uncontested deletes stay deleted", () => {
const base = [note("a", "base")];
const edited = [note("a", "changed")];
assert.deepEqual(rebasePendingVaultNotes({ base, ours: [], theirs: edited }), edited);
assert.deepEqual(rebasePendingVaultNotes({ base, ours: edited, theirs: [] }), edited);
assert.deepEqual(rebasePendingVaultNotes({ base, ours: [], theirs: base }), []);
assert.deepEqual(rebasePendingVaultNotes({ base, ours: base, theirs: [] }), []);
});
test("equal local and remote notes do not produce a conflict copy", () => {
const base = [note("a", "base")];
const edited = [note("a", "same")];
assert.deepEqual(rebasePendingVaultNotes({ base, ours: edited, theirs: edited }), edited);
assert.deepEqual(rebasePendingVaultNotes({ base: [], ours: edited, theirs: edited }), edited);
});
+40
View File
@@ -0,0 +1,40 @@
import type { VaultNote } from "./models.ts";
import { normalizeVaultNotes } from "./notes.ts";
import { getNextVaultOrder } from "./vaultOrder.ts";
/** Reconcile a pending local write with the last observed storage snapshot.
* Keep conflicting remote content as an ordinary, exportable note; timestamps
* are compared only for equality, never used to decide which writer wins.
*/
export function rebasePendingVaultNotes(input: {
base: VaultNote[];
ours: VaultNote[];
theirs: VaultNote[];
}): VaultNote[] {
const base = new Map(input.base.map(note => [note.id, note]));
const ours = new Map(input.ours.map(note => [note.id, note]));
const theirs = new Map(input.theirs.map(note => [note.id, note]));
const same = (a: VaultNote | undefined, b: VaultNote | undefined) =>
JSON.stringify(a) === JSON.stringify(b);
const notes: VaultNote[] = [];
const copies: VaultNote[] = [];
let copyOrder = getNextVaultOrder([...input.ours, ...input.theirs]);
for (const id of new Set([...ours.keys(), ...theirs.keys()])) {
const ancestor = base.get(id);
const local = ours.get(id);
const remote = theirs.get(id);
if (same(local, ancestor)) {
if (remote) notes.push(remote);
} else if (same(remote, ancestor) || same(local, remote)) {
if (local) notes.push(local);
} else {
if (local) notes.push(local);
if (remote) {
// A concurrent delete must not discard the surviving edited content.
if (!local) notes.push(remote);
else copies.push({ ...remote, id: crypto.randomUUID(), title: `${remote.title} (conflict copy)`, order: copyOrder++ });
}
}
}
return normalizeVaultNotes([...notes, ...copies]);
}