mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 06:52:07 +00:00
First PR of the **E2 collab safety epic** — see `docs/architecture-collab.md` for the full design (3-way merge UI + offline write queue). This PR adds the foundation: a per-session LRU snapshot store the 3-way merge UI will pull from on a write conflict. The next time the user reads a file, we stash the (path, content, mtime) here. On a subsequent PreconditionFailed write, the conflict modal pulls the recorded ancestor out and shows three real panes (ancestor / mine / theirs) instead of the current overwrite-or-cancel two-shot. Pieces: - `plugin/src/conflict/AncestorTracker.ts` — LRU on byte size (default 64 MB, matches `ReadCache`). Soft-cap: eviction shrinks to 90 % of the cap so a steady-state workload doesn't evict on every Put. Counts UTF-8 bytes (without depending on `Buffer` so the module stays portable). - `plugin/tests/AncestorTracker.test.ts` — 13 cases: round-trip, invalidate, clear, byte accounting (ASCII / CJK / surrogate-pair emoji), eviction order, LRU touch keeps a hot entry alive, stats shape. - `docs/architecture-collab.md` — design record for the entire E2 epic (mermaid sequences + state diagrams, F-IDs, phased PR plan, defaults, verification approach). Implementation note: the LRU "lastAccessed" tick is a plain monotonic counter, not `Date.now()`. `Date.now() * 1e6` already overflows JavaScript's safe-integer range (> 2^53), so a counter-discriminator added to it would silently round away — caught the bug while writing the test for the LRU touch, fixed before commit. Plugin client wiring (Modal + adapter hook) lands in PRs E2-α.2 and E2-α.3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
123 lines
5 KiB
TypeScript
123 lines
5 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
|
import { AncestorTracker } from '../src/conflict/AncestorTracker';
|
|
|
|
describe('AncestorTracker', () => {
|
|
// ─── basic round-trip ─────────────────────────────────────────────────────
|
|
|
|
it('returns null for an unknown path', () => {
|
|
const t = new AncestorTracker();
|
|
expect(t.get('not-tracked.md')).toBeNull();
|
|
});
|
|
|
|
it('round-trips remember → get with content + mtime', () => {
|
|
const t = new AncestorTracker();
|
|
t.remember('note.md', 'hello world', 1700);
|
|
expect(t.get('note.md')).toEqual({ content: 'hello world', mtime: 1700 });
|
|
});
|
|
|
|
it('remembering the same path replaces the previous snapshot', () => {
|
|
const t = new AncestorTracker();
|
|
t.remember('note.md', 'old', 1);
|
|
t.remember('note.md', 'new', 2);
|
|
expect(t.get('note.md')).toEqual({ content: 'new', mtime: 2 });
|
|
expect(t.stats().entries).toBe(1);
|
|
});
|
|
|
|
// ─── invalidation / clear ────────────────────────────────────────────────
|
|
|
|
it('invalidate drops the entry and frees its bytes', () => {
|
|
const t = new AncestorTracker();
|
|
t.remember('a.md', 'aaa', 1);
|
|
t.remember('b.md', 'bbb', 2);
|
|
expect(t.stats().entries).toBe(2);
|
|
t.invalidate('a.md');
|
|
expect(t.get('a.md')).toBeNull();
|
|
expect(t.stats().entries).toBe(1);
|
|
});
|
|
|
|
it('clear empties everything', () => {
|
|
const t = new AncestorTracker();
|
|
t.remember('a.md', 'aaa', 1);
|
|
t.remember('b.md', 'bbb', 2);
|
|
t.clear();
|
|
expect(t.stats()).toEqual({ entries: 0, bytes: 0, maxBytes: AncestorTracker.DEFAULT_MAX_BYTES });
|
|
});
|
|
|
|
// ─── byte accounting ─────────────────────────────────────────────────────
|
|
|
|
it('counts ASCII as one byte per char', () => {
|
|
const t = new AncestorTracker();
|
|
t.remember('a.md', 'abcde', 1);
|
|
expect(t.stats().bytes).toBe(5);
|
|
});
|
|
|
|
it('counts a 4-byte CJK char correctly (3 bytes per BMP char)', () => {
|
|
const t = new AncestorTracker();
|
|
t.remember('jp.md', '日本語', 1); // each char is U+65E5/U+672C/U+8A9E, all BMP → 3 bytes
|
|
expect(t.stats().bytes).toBe(9);
|
|
});
|
|
|
|
it('counts an astral-plane (surrogate-pair) emoji as 4 bytes', () => {
|
|
const t = new AncestorTracker();
|
|
t.remember('e.md', '😀', 1); // U+1F600 → surrogate pair, 4 UTF-8 bytes
|
|
expect(t.stats().bytes).toBe(4);
|
|
});
|
|
|
|
// ─── LRU eviction ────────────────────────────────────────────────────────
|
|
|
|
it('evicts oldest entries when total bytes cross maxBytes', () => {
|
|
// Cap at 100 bytes; each entry is 40 bytes → 2 fit, the 3rd triggers eviction.
|
|
const t = new AncestorTracker(100);
|
|
const payload = 'x'.repeat(40);
|
|
t.remember('first.md', payload, 1);
|
|
t.remember('second.md', payload, 2);
|
|
t.remember('third.md', payload, 3);
|
|
|
|
// Eviction shrinks to 90 % of cap = 90 bytes; with 40-byte entries
|
|
// the only outcome below 90 is 1 or 2 entries (40 or 80 bytes).
|
|
// The one we just remembered (third) must survive; "first" should
|
|
// be the dropped one (oldest lastAccessed).
|
|
expect(t.get('third.md')).not.toBeNull();
|
|
expect(t.get('first.md')).toBeNull();
|
|
const after = t.stats();
|
|
expect(after.bytes).toBeLessThanOrEqual(t['maxBytes' as keyof typeof t] as number);
|
|
});
|
|
|
|
it('LRU touch on get keeps a hot entry alive across eviction', () => {
|
|
const t = new AncestorTracker(100);
|
|
const payload = 'x'.repeat(40);
|
|
t.remember('cold.md', payload, 1);
|
|
t.remember('hot.md', payload, 2);
|
|
// Touch 'cold' so it becomes more recent than 'hot'.
|
|
t.get('cold.md');
|
|
// Add a third entry — eviction should now drop 'hot' (oldest by lastAccessed).
|
|
t.remember('newest.md', payload, 3);
|
|
|
|
expect(t.get('cold.md')).not.toBeNull();
|
|
expect(t.get('hot.md')).toBeNull();
|
|
expect(t.get('newest.md')).not.toBeNull();
|
|
});
|
|
|
|
it('replacing an existing entry adjusts byte accounting (no double-counting)', () => {
|
|
const t = new AncestorTracker();
|
|
t.remember('a.md', 'short', 1);
|
|
expect(t.stats().bytes).toBe(5);
|
|
t.remember('a.md', 'much longer payload', 2);
|
|
expect(t.stats().bytes).toBe('much longer payload'.length);
|
|
});
|
|
|
|
it('invalidate of a missing key is a no-op (no negative bytes)', () => {
|
|
const t = new AncestorTracker();
|
|
t.remember('a.md', 'aaa', 1);
|
|
const before = t.stats().bytes;
|
|
t.invalidate('missing.md');
|
|
expect(t.stats().bytes).toBe(before);
|
|
});
|
|
|
|
// ─── stats() shape ───────────────────────────────────────────────────────
|
|
|
|
it('stats reports the configured maxBytes', () => {
|
|
const t = new AncestorTracker(12345);
|
|
expect(t.stats().maxBytes).toBe(12345);
|
|
});
|
|
});
|