mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c64196670 | ||
|
|
114f80b6c4 | ||
|
|
57e48342e4 |
No files matched your search
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in new issue
Block a user