mirror of
https://github.com/sotashimozono/obsidian-remote-ssh.git
synced 2026-07-22 17:10:32 +00:00
Closes the offline-write loop. After an SSH session recovers (or
on a fresh connect that finds leftover entries from a previous
Electron session), the queued ops drain serially against the live
adapter. PreconditionFailed during drain routes through the same
3-way merge UI from E2-α; transport errors leave entries queued
for the next reconnect.
Pieces:
- `plugin/src/adapter/SftpDataAdapter.ts`
- New `replayQueuedOp(op)` method. Dispatches by op kind; for
text/binary writes it threads the queued op's `expectedMtime`
through `writeBuffer` (new optional `expectedMtimeOverride`
parameter) so PreconditionFailed checks fire against the mtime
the user actually saw at enqueue time, not the synthetic mtime
the offline cache update wrote.
- Returns a discriminated outcome: `ok` / `conflict` / `error`.
`conflict` is treated as user-decided (queue advances);
`error` halts the drain (queue retries next reconnect).
- `queueOrThrowText` no longer refreshes the ancestor tracker
on offline writes — keeping the original "what user read"
snapshot is what makes the conflict modal useful at replay
time. Without this, the modal would show (mine, mine, theirs)
instead of (ancestor, mine, theirs).
- `plugin/src/offline/QueueReplayer.ts` (new)
- One-shot driver: instantiate, call `run()`, throw away.
- Drains oldest-first.
- Reports `{ drained, conflicts, errors }`.
- Stops on first `error` so a chain of edits to the same file
keeps order (skipping risks a write on a path that depends on
an earlier rename / mkdir).
- `plugin/src/main.ts`
- New `replayOfflineQueue(label)` helper.
- Triggered fire-and-forget on:
- Reconnect 'recovered' state (`onReconnectStateChange`).
- Initial connect success (so a previous-session crash leaves
pending entries that drain when the user reconnects).
- Surfaces "replayed N edits" / "N failed to replay" notices.
Tests:
- `plugin/tests/QueueReplayer.test.ts` (7 cases): empty queue,
oldest-first happy path, conflict counts as decided, error
stops the drain, target throw treated as error, partial drain
resumes on next replayer instance, all op kinds pass through.
- 5 new SftpDataAdapter cases for `replayQueuedOp`: write honours
queued mtime, conflict outcome on PreconditionFailed without
ancestor, mkdir/remove/rename/copy dispatch, error outcome on
non-precondition throw, refuses to run while reconnecting.
- All 412 unit tests pass (400 prior + 12 new).
Phase E2-β functionality complete; β.4 (StatusBar pending-edits
indicator + discard modal) is the polish PR that follows.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
166 lines
6.7 KiB
TypeScript
166 lines
6.7 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import * as fs from 'node:fs/promises';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import { OfflineQueue, type QueuedOp } from '../src/offline/OfflineQueue';
|
|
import { QueueReplayer, type ReplayTarget } from '../src/offline/QueueReplayer';
|
|
|
|
async function tempQueue(label: string, ops: QueuedOp[] = []): Promise<OfflineQueue> {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), `remote-ssh-replayer-${label}-`));
|
|
const q = await OfflineQueue.open(dir);
|
|
for (const op of ops) await q.enqueue(op);
|
|
return q;
|
|
}
|
|
|
|
function textWrite(p: string, content: string): QueuedOp {
|
|
return { kind: 'write', path: p, contentBase64: Buffer.from(content, 'utf8').toString('base64') };
|
|
}
|
|
|
|
function makeTarget(answers: Array<Awaited<ReturnType<ReplayTarget['replayQueuedOp']>>>): {
|
|
target: ReplayTarget;
|
|
calls: QueuedOp[];
|
|
} {
|
|
const calls: QueuedOp[] = [];
|
|
let i = 0;
|
|
return {
|
|
calls,
|
|
target: {
|
|
async replayQueuedOp(op) {
|
|
calls.push(op);
|
|
if (i >= answers.length) {
|
|
throw new Error(`fake target: no more answers programmed (call #${i + 1})`);
|
|
}
|
|
return answers[i++];
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('QueueReplayer', () => {
|
|
// ─── happy paths ────────────────────────────────────────────────────────
|
|
|
|
it('drains an empty queue without calling the target', async () => {
|
|
const q = await tempQueue('empty');
|
|
const { target, calls } = makeTarget([]);
|
|
const report = await new QueueReplayer(q, target).run();
|
|
expect(report).toEqual({ drained: 0, conflicts: 0, errors: [] });
|
|
expect(calls).toHaveLength(0);
|
|
});
|
|
|
|
it('drains a 3-entry queue oldest-first when every op succeeds', async () => {
|
|
const q = await tempQueue('happy', [
|
|
textWrite('a.md', 'one'),
|
|
textWrite('b.md', 'two'),
|
|
textWrite('c.md', 'three'),
|
|
]);
|
|
const { target, calls } = makeTarget([
|
|
{ result: 'ok' }, { result: 'ok' }, { result: 'ok' },
|
|
]);
|
|
const report = await new QueueReplayer(q, target).run();
|
|
expect(report.drained).toBe(3);
|
|
expect(report.conflicts).toBe(0);
|
|
expect(report.errors).toEqual([]);
|
|
expect(calls.map(o => (o as { path: string }).path)).toEqual(['a.md', 'b.md', 'c.md']);
|
|
expect(q.pending()).toEqual([]);
|
|
});
|
|
|
|
// ─── conflict counts as decided, not queued ─────────────────────────────
|
|
|
|
it('marks conflict-resolved entries completed (does not re-enqueue)', async () => {
|
|
const q = await tempQueue('conflict', [
|
|
textWrite('a.md', 'one'),
|
|
textWrite('b.md', 'two'),
|
|
]);
|
|
const { target } = makeTarget([
|
|
{ result: 'conflict' }, // user picked keep-theirs
|
|
{ result: 'ok' },
|
|
]);
|
|
const report = await new QueueReplayer(q, target).run();
|
|
expect(report.drained).toBe(1);
|
|
expect(report.conflicts).toBe(1);
|
|
expect(q.pending()).toEqual([]);
|
|
});
|
|
|
|
// ─── error stops the drain ──────────────────────────────────────────────
|
|
|
|
it('stops on the first error and leaves later entries queued for next reconnect', async () => {
|
|
const q = await tempQueue('error', [
|
|
textWrite('a.md', 'one'),
|
|
textWrite('b.md', 'two'),
|
|
textWrite('c.md', 'three'),
|
|
]);
|
|
const { target, calls } = makeTarget([
|
|
{ result: 'ok' },
|
|
{ result: 'error', message: 'connection reset' },
|
|
{ result: 'ok' }, // never reached
|
|
]);
|
|
const report = await new QueueReplayer(q, target).run();
|
|
expect(report.drained).toBe(1);
|
|
expect(report.errors).toEqual([{ id: expect.any(Number), message: 'connection reset' }]);
|
|
expect(calls).toHaveLength(2); // 'c.md' never attempted
|
|
const remaining = q.pending().map(e => (e.op as { path: string }).path);
|
|
expect(remaining).toEqual(['b.md', 'c.md']);
|
|
});
|
|
|
|
// ─── target throw treated as error ──────────────────────────────────────
|
|
|
|
it('treats a thrown target as an error and stops', async () => {
|
|
const q = await tempQueue('throw', [
|
|
textWrite('a.md', 'one'),
|
|
textWrite('b.md', 'two'),
|
|
]);
|
|
const target: ReplayTarget = {
|
|
async replayQueuedOp() { throw new Error('disk fire'); },
|
|
};
|
|
const report = await new QueueReplayer(q, target).run();
|
|
expect(report.errors).toEqual([{ id: expect.any(Number), message: 'disk fire' }]);
|
|
expect(report.drained).toBe(0);
|
|
expect(q.pending()).toHaveLength(2);
|
|
});
|
|
|
|
// ─── partial drain across reconnects ────────────────────────────────────
|
|
|
|
it('next reconnect resumes from the entry that errored', async () => {
|
|
const q = await tempQueue('resume', [
|
|
textWrite('a.md', 'one'),
|
|
textWrite('b.md', 'two'),
|
|
textWrite('c.md', 'three'),
|
|
]);
|
|
// First attempt: 'a' ok, 'b' errors → stop.
|
|
const first = makeTarget([
|
|
{ result: 'ok' },
|
|
{ result: 'error', message: 'transient' },
|
|
]);
|
|
const r1 = await new QueueReplayer(q, first.target).run();
|
|
expect(r1.drained).toBe(1);
|
|
expect(q.pending()).toHaveLength(2);
|
|
|
|
// Second attempt: 'b' ok, 'c' ok.
|
|
const second = makeTarget([{ result: 'ok' }, { result: 'ok' }]);
|
|
const r2 = await new QueueReplayer(q, second.target).run();
|
|
expect(r2.drained).toBe(2);
|
|
expect(q.pending()).toEqual([]);
|
|
expect(second.calls.map(o => (o as { path: string }).path)).toEqual(['b.md', 'c.md']);
|
|
});
|
|
|
|
// ─── op dispatching is left to the target ───────────────────────────────
|
|
|
|
it('passes every op kind through verbatim to the target', async () => {
|
|
const allKinds: QueuedOp[] = [
|
|
{ kind: 'write', path: 'a', contentBase64: 'YQ==' },
|
|
{ kind: 'writeBinary', path: 'b', contentBase64: 'Yg==' },
|
|
{ kind: 'append', path: 'c', contentBase64: 'Yw==' },
|
|
{ kind: 'appendBinary', path: 'd', contentBase64: 'ZA==' },
|
|
{ kind: 'mkdir', path: 'e' },
|
|
{ kind: 'remove', path: 'f' },
|
|
{ kind: 'rmdir', path: 'g', recursive: false },
|
|
{ kind: 'rename', oldPath: 'h', newPath: 'i' },
|
|
{ kind: 'copy', srcPath: 'j', dstPath: 'k' },
|
|
{ kind: 'trashLocal', path: 'l' },
|
|
];
|
|
const q = await tempQueue('kinds', allKinds);
|
|
const { target, calls } = makeTarget(allKinds.map(() => ({ result: 'ok' as const })));
|
|
await new QueueReplayer(q, target).run();
|
|
expect(calls).toEqual(allKinds);
|
|
});
|
|
});
|