sotashimozono_obsidian-remo.../plugin/tests/integration/sync.e2e.test.ts
Souta 6867b5eadb test(framework): Layer 1 — writer self-reflect harness (failing tests for #341)
Adds the writer-side counterpart to the existing M9 sync-reflect E2E
matrix. Refactors `HarnessVault` + `asArrayBuffer` out of
`sync.e2e.test.ts` so the new self-reflect suite (and the upcoming
restart + invariant suites) can share them without duplicating the
event-bus semantics.

Layer 1 ships:

- `helpers/harnessVault.ts` — `HarnessVault`/`HarnessTFile`/
  `HarnessTFolder`/`asArrayBuffer` extracted from `sync.e2e.test.ts`.
  No behaviour change.

- `helpers/assertSelfReflect.ts` — writer-side variant of
  `assertSyncReflect`. Same span-and-budget contract, but the
  FakeFileExplorer is attached to the *writer's* vault rather than
  a reader peer.

- `self-reflect.e2e.test.ts` — 5 `it.fails(...)` cases documenting
  the contract `SftpDataAdapter` should satisfy:

    * adapter.write fires vault.trigger('create')
    * adapter.write (overwrite) fires vault.trigger('modify')
    * adapter.rename fires vault.trigger('rename')        ← issue #341
    * adapter.remove fires vault.trigger('delete')
    * post-rename writer.vault.fileMap reflects the new path

The `.fails` markers come off in the #341 fix PR; that PR's diff
then proves the framework caught the regression.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-12 09:16:22 +09:00

607 lines
25 KiB
TypeScript

import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import type { Vault } from 'obsidian';
import { perfTracer } from '../../src/util/PerfTracer';
import * as os from 'node:os';
import * as path from 'node:path';
import { SftpDataAdapter } from '../../src/adapter/SftpDataAdapter';
import { RpcRemoteFsClient } from '../../src/adapter/RpcRemoteFsClient';
import { ReadCache } from '../../src/cache/ReadCache';
import { DirCache } from '../../src/cache/DirCache';
import { ConflictResolver, type ThreeWayPanes } from '../../src/conflict/ConflictResolver';
import { AncestorTracker } from '../../src/conflict/AncestorTracker';
import { OfflineQueue, type QueuedOp } from '../../src/offline/OfflineQueue';
import { QueueReplayer } from '../../src/offline/QueueReplayer';
import { VaultModelBuilder, type ObsidianClassDeps } from '../../src/vault/VaultModelBuilder';
import type { FsChangedParams } from '../../src/proto/types';
import { deployTestDaemon, LOCAL_DAEMON_BINARY, type DeployedDaemon } from './helpers/deployDaemonOnce';
import { TEST_PRIVATE_KEY, TEST_VAULT } from './helpers/makeAdapter';
import { buildRpcClient, type RpcClientHandle } from './helpers/multiclientRpc';
import { FakeFileExplorer } from '../helpers/FakeFileExplorer';
import { assertSyncReflect } from './helpers/assertSyncReflect';
import { HarnessVault, HarnessTFile, HarnessTFolder, asArrayBuffer } from './helpers/harnessVault';
/**
* Phase C M9 — sync-reflect E2E matrix (foundational slice).
*
* Composes the full Phase C pipeline end-to-end in one Node process:
*
* writer SftpDataAdapter (M2 spans) ← writer-side
* → RpcRemoteFsClient.write* (M2 spans)
* → daemon (M3 cid correlator + atomicWriteFile)
* → fsnotify
* → fs.changed notification (M3 envelope meta)
* → reader RpcClient.onNotification
* → applyFsChange-equivalent (T4a + S.app spans)
* → VaultModelBuilder mutator (T5a span)
* → fakeVault.trigger(create|modify|...)
* → FakeFileExplorer (M7) — the T5 observation
* → assertSyncReflect (M8) — single assertion
*
* Cases shipped in this PR (the foundational slice):
*
* - create: new file at a never-seen path
* - delete: remove a previously-created file
* - rename: move within the same parent
*
* Intentionally deferred (will land in M9b/M9c follow-ups):
*
* - modify: same fsnotify "no IN_MOVED_TO across-watcher
* atomic-rename" quirk that M6's bench skipped
* on; the per-iter re-subscribe workaround needs
* its own design pass.
* - large (10 MB) / binary content-hash: bandwidth bench
* - 3-way conflict: ThreeWayMergeModal
* stub or real flow
* - disconnect/reconnect: OfflineQueue +
* ReconnectManager
* orchestration
*
* Runs only when the test keypair + daemon binary are staged
* (`npm run sshd:start` + `npm run build:server`); skipped otherwise.
*/
if (!fs.existsSync(TEST_PRIVATE_KEY)) {
throw new Error(
`Integration test keypair missing at ${TEST_PRIVATE_KEY}. ` +
'Run `npm run sshd:start` from the repo root before `npm run test:integration`.',
);
}
if (!fs.existsSync(LOCAL_DAEMON_BINARY)) {
throw new Error(
`Daemon binary missing at ${LOCAL_DAEMON_BINARY}. ` +
'Run `npm run build:server` before `npm run test:integration`.',
);
}
// ── per-suite plumbing ─────────────────────────────────────────────────
const PER_CASE_BUDGET_MS = 3_000;
describe('Phase C E2E — sync reflect matrix', () => {
let daemon: DeployedDaemon;
let writer: RpcClientHandle;
let reader: RpcClientHandle;
let writerAdapter: SftpDataAdapter;
let readerAdapter: SftpDataAdapter;
let harnessVault: HarnessVault;
let fakeFE: FakeFileExplorer;
let detachFE: (() => void) | null = null;
let watchSubId: string | null = null;
let unsubscribeNotify: (() => void) | null = null;
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const subdirRel = `e2e-${stamp}`;
beforeAll(async () => {
perfTracer.clear();
perfTracer.setEnabled(true);
daemon = await deployTestDaemon({ label: 'e2e' });
writer = await buildRpcClient(daemon.result.remoteSocketPath, daemon.result.token, 'e2e-writer');
reader = await buildRpcClient(daemon.result.remoteSocketPath, daemon.result.token, 'e2e-reader');
writerAdapter = new SftpDataAdapter(
new RpcRemoteFsClient(writer.conn.rpc),
'',
new ReadCache({ maxBytes: 64 * 1024 * 1024 }),
new DirCache(),
'e2e-writer',
);
readerAdapter = new SftpDataAdapter(
new RpcRemoteFsClient(reader.conn.rpc),
'',
new ReadCache({ maxBytes: 64 * 1024 * 1024 }),
new DirCache(),
'e2e-reader',
);
// Build the reader's synthetic vault + file-explorer surface and
// wire the M9 reader pipeline (notification → builder → vault.trigger
// → FakeFileExplorer.observe via attach).
harnessVault = new HarnessVault();
fakeFE = new FakeFileExplorer();
detachFE = fakeFE.attach(harnessVault as unknown as Vault);
const builder = new VaultModelBuilder(
harnessVault as unknown as Vault,
{ TFile: HarnessTFile as unknown as ObsidianClassDeps['TFile'],
TFolder: HarnessTFolder as unknown as ObsidianClassDeps['TFolder'] },
);
unsubscribeNotify = reader.conn.rpc.onNotification('fs.changed', (params: FsChangedParams) => {
// Filter out events from outside our subdir so other integration
// tests' detritus (or our own setup mkdir) doesn't pollute the
// assertions.
if (!params.path.startsWith(subdirRel)) return;
handleFsChangedForReader(params, builder, readerAdapter);
});
// Subscribe to fs.changed on the entire vault (parent dir is
// already watched by the daemon's startup walk; recursive=true
// catches new sub-tree creation). Per-case re-subscription is
// not needed for create/delete/rename cells — only modify hits
// the "across-watcher atomic-rename swallows IN_MOVED_TO" quirk.
const subResult = await reader.conn.rpc.call('fs.watch', { path: '', recursive: true });
watchSubId = subResult.subscriptionId;
// Bootstrap the FakeVault with the subdir folder so per-case
// VaultModelBuilder.insertOne(file) can resolve its parent.
// Done via the writer so the daemon emits a `created` notification
// → reader handler → builder.insertOne → fakeFE.observe.
await writerAdapter.mkdir(subdirRel);
await fakeFE.awaitReflect(subdirRel, 'create', 5_000);
});
afterAll(async () => {
try { detachFE?.(); } catch { /* best effort */ }
try { unsubscribeNotify?.(); } catch { /* best effort */ }
if (watchSubId) {
try { await reader.conn.rpc.call('fs.unwatch', { subscriptionId: watchSubId }); }
catch { /* daemon may already be down */ }
}
try { await writer.close(); } catch { /* best effort */ }
try { await reader.close(); } catch { /* best effort */ }
if (daemon) await daemon.teardown();
perfTracer.setEnabled(false);
perfTracer.clear();
});
beforeEach(() => {
perfTracer.clear();
});
afterEach(() => {
// Don't reset fakeFE — between cases, the synthetic vault should
// accumulate state the same way Obsidian's real vault does, so a
// delete-after-create case can look up the previously-created file.
// The per-suite stamp keeps cases from colliding on path names.
});
// ── cases ───────────────────────────────────────────────────────────
it('create — writer create reflects on reader\'s FakeFileExplorer', async () => {
const target = `${subdirRel}/note-create.bin`;
const data = Buffer.from('hello-create');
const r = await assertSyncReflect({
label: 'create',
op: () => writerAdapter.writeBinary(target, asArrayBuffer(data)),
reader: { fakeFE },
expect: { path: target, event: 'create' },
budgetMs: PER_CASE_BUDGET_MS,
});
expect(r.e2eMs).toBeGreaterThan(0);
expect(fakeFE.snapshot().paths).toContain(target);
});
it('delete — writer delete reflects as a `delete` event on reader\'s FE', async () => {
const target = `${subdirRel}/note-delete.bin`;
const data = Buffer.from('to-be-deleted');
// Pre-create through the same E2E pipeline so the FakeFE knows
// the path exists before the deletion under test.
await assertSyncReflect({
label: 'delete (pre-create)',
op: () => writerAdapter.writeBinary(target, asArrayBuffer(data)),
reader: { fakeFE },
expect: { path: target, event: 'create' },
budgetMs: PER_CASE_BUDGET_MS,
});
const r = await assertSyncReflect({
label: 'delete',
op: () => writerAdapter.remove(target),
reader: { fakeFE },
expect: { path: target, event: 'delete' },
budgetMs: PER_CASE_BUDGET_MS,
});
expect(r.e2eMs).toBeGreaterThan(0);
expect(fakeFE.snapshot().paths).not.toContain(target);
});
it('rename — writer rename reflects as a `rename` event on the new path', async () => {
const oldPath = `${subdirRel}/note-rename-src.bin`;
const newPath = `${subdirRel}/note-rename-dst.bin`;
const data = Buffer.from('renamed');
await assertSyncReflect({
label: 'rename (pre-create)',
op: () => writerAdapter.writeBinary(oldPath, asArrayBuffer(data)),
reader: { fakeFE },
expect: { path: oldPath, event: 'create' },
budgetMs: PER_CASE_BUDGET_MS,
});
// The daemon's atomic-rename fires `deleted` on the old path AND
// `created` on the new path; the M3 cid correlator stamps the
// same cid on both notifications. The reader handler dispatches
// the `deleted` to builder.removeOne (fires `delete` on FE) and
// the `created` to builder.insertOne (fires `create` on FE).
// We assert the `create` on the new path — the visible vault
// outcome — and verify the old path is gone afterwards.
const r = await assertSyncReflect({
label: 'rename',
op: () => writerAdapter.rename(oldPath, newPath),
reader: { fakeFE },
expect: { path: newPath, event: 'create' },
budgetMs: PER_CASE_BUDGET_MS,
});
expect(r.e2eMs).toBeGreaterThan(0);
const snap = fakeFE.snapshot().paths;
expect(snap).toContain(newPath);
// Old path may take a beat longer to purge if the `deleted`
// notification trails behind the `created` one. Give it the
// same budget as a separate awaitReflect.
await fakeFE.awaitReflect(oldPath, 'delete', PER_CASE_BUDGET_MS).catch(() => undefined);
expect(fakeFE.snapshot().paths).not.toContain(oldPath);
});
// nested-folder: previously skipped because the daemon's fsnotify
// auto-watch dropped IN_CREATE for descendants of a directory
// created via os.MkdirAll (the kernel created the children before
// the dispatch goroutine got a chance to call notify.Add on the
// parent). Closed by #107 — the watcher now walks the new sub-tree
// and emits synthetic `created` events for any descendants caught
// by the race window. See server/internal/watcher/watcher.go's
// catchUpAfterRace.
it('nested-folder — writer write into a deep new sub-tree reflects parents + child', async () => {
const dir1 = `${subdirRel}/level1`;
const dir2 = `${subdirRel}/level1/level2`;
const target = `${dir2}/leaf.bin`;
const data = Buffer.from('deep');
const r = await assertSyncReflect({
label: 'nested-folder',
op: () => writerAdapter.writeBinary(target, asArrayBuffer(data)),
reader: { fakeFE },
expect: { path: target, event: 'create' },
budgetMs: PER_CASE_BUDGET_MS * 2,
});
expect(r.e2eMs).toBeGreaterThan(0);
const snap = fakeFE.snapshot().paths;
expect(snap).toContain(dir1);
expect(snap).toContain(dir2);
expect(snap).toContain(target);
});
// ── M9b cases (previously deferred) ──────────────────────────────
it('modify — writer overwrite reflects as a `modify` event on reader', async () => {
const target = `${subdirRel}/note-modify.bin`;
await assertSyncReflect({
label: 'modify (pre-create)',
op: () => writerAdapter.writeBinary(target, asArrayBuffer(Buffer.from('v1'))),
reader: { fakeFE },
expect: { path: target, event: 'create' },
budgetMs: PER_CASE_BUDGET_MS,
});
const r = await assertSyncReflect({
label: 'modify',
op: () => writerAdapter.writeBinary(target, asArrayBuffer(Buffer.from('v2'))),
reader: { fakeFE },
expect: { path: target, event: 'modify' },
budgetMs: PER_CASE_BUDGET_MS,
});
expect(r.e2eMs).toBeGreaterThan(0);
expect(fakeFE.snapshot().paths).toContain(target);
});
it('large file (10 MB) — bandwidth path exercises daemon + reader stat', async () => {
// #109 spec: 10 MB pushes the wire-transfer envelope and verifies
// the daemon doesn't time out on long writes + the reader's
// applyFsChange survives a slow stat against a freshly-flushed
// multi-MB inode. Budget x20 (vs x5 for the old 1 MB case) gives
// generous headroom for CI's typical SSH RTT without burying real
// regressions: at ~50 MB/s effective sftp throughput the wire
// transfer alone is ~200 ms, plus the reader's fs.changed → stat
// round-trip.
const target = `${subdirRel}/note-large.bin`;
const data = Buffer.alloc(10 * 1024 * 1024, 0x42);
const r = await assertSyncReflect({
label: 'large-file-10mb',
op: () => writerAdapter.writeBinary(target, asArrayBuffer(data)),
reader: { fakeFE },
expect: { path: target, event: 'create' },
budgetMs: PER_CASE_BUDGET_MS * 20,
});
expect(r.e2eMs).toBeGreaterThan(0);
expect(fakeFE.snapshot().paths).toContain(target);
});
it('binary content-hash round-trip — SHA-256 matches after write → read', async () => {
const crypto = await import('node:crypto');
const target = `${subdirRel}/note-binary.bin`;
// Non-ASCII binary blob with a mix of byte values
const data = Buffer.from(Array.from({ length: 4096 }, (_, i) => i % 256));
const expectedHash = crypto.createHash('sha256').update(data).digest('hex');
await assertSyncReflect({
label: 'binary-hash (create)',
op: () => writerAdapter.writeBinary(target, asArrayBuffer(data)),
reader: { fakeFE },
expect: { path: target, event: 'create' },
budgetMs: PER_CASE_BUDGET_MS,
});
// Read back through the reader adapter and compare SHA-256
const readBack = await readerAdapter.readBinary(target);
const actualHash = crypto.createHash('sha256').update(Buffer.from(readBack)).digest('hex');
expect(actualHash).toBe(expectedHash);
});
it('3-way conflict — stale expectedMtime triggers PreconditionFailed from daemon', async () => {
const target = `${subdirRel}/note-conflict.md`;
// Writer A creates the file; its readCache learns the mtime.
await writerAdapter.write(target, 'version-1');
// Writer B (readerAdapter) reads → primes its own cache with mtime M1.
const v1 = await readerAdapter.read(target);
expect(v1).toBe('version-1');
// Writer A overwrites → the remote mtime advances to M2.
await writerAdapter.write(target, 'version-2');
// Writer B tries to write with its stale cached mtime M1.
// The daemon sees actual mtime M2 ≠ M1 → PreconditionFailed (-32020).
// readerAdapter has no ConflictResolver, so it rethrows.
await expect(readerAdapter.write(target, 'version-3'))
.rejects.toMatchObject({ code: -32020 });
// The remote still holds writer A's content.
const final = await writerAdapter.read(target);
expect(final).toBe('version-2');
});
it('3-way conflict — onTextConflict receives {ancestor, mine, theirs} and keep-mine wins', async () => {
// #109: deeper variant — the previous test asserted the daemon
// rejects with PreconditionFailed; this one wires a real
// ConflictResolver + AncestorTracker on a third "Bob" adapter
// and verifies the full 3-way merge pipeline:
//
// Alice creates "version-1" (becomes Bob's ancestor)
// Bob reads → ancestor remembered
// Alice overwrites with "alice-edit" (becomes "theirs" on Bob)
// Bob writes "bob-edit" → PreconditionFailed
// → ConflictResolver.resolve → onTextConflict({...})
// → stub returns keep-mine
// Remote ends up holding "bob-edit"
//
// Validates the production wiring used by ThreeWayMergeModal —
// the previous test only proved the daemon rejects, not that the
// resolver chain runs end-to-end.
const target = `${subdirRel}/note-conflict-3way.md`;
const bobReadCache = new ReadCache({ maxBytes: 64 * 1024 * 1024 });
const bobDirCache = new DirCache();
const bobAncestor = new AncestorTracker();
const recorded: Array<{ vaultPath: string; panes: ThreeWayPanes }> = [];
const onTextConflict = async (vaultPath: string, panes: ThreeWayPanes) => {
recorded.push({ vaultPath, panes });
return { decision: 'keep-mine' as const };
};
const bobConflictResolver = new ConflictResolver(
new RpcRemoteFsClient(reader.conn.rpc),
bobReadCache,
bobAncestor,
onTextConflict,
null, // no two-choice fallback — text path is what we're exercising
);
const bobAdapter = new SftpDataAdapter(
new RpcRemoteFsClient(reader.conn.rpc),
'',
bobReadCache,
bobDirCache,
'e2e-bob',
null, // pathMapper
null, // resourceBridge
bobConflictResolver,
bobAncestor,
);
// Alice creates "version-1" (Bob's eventual ancestor pane).
await writerAdapter.write(target, 'version-1');
// Bob reads — primes both readCache (mtime M1) and ancestorTracker.
const v1 = await bobAdapter.read(target);
expect(v1).toBe('version-1');
// Alice overwrites — remote mtime advances to M2; this becomes
// Bob's "theirs" pane.
await writerAdapter.write(target, 'alice-edit');
// Bob writes with stale M1 → PreconditionFailed → ConflictResolver
// sees ancestor + onTextConflict wired → calls the stub.
await bobAdapter.write(target, 'bob-edit');
// Stub got called exactly once with the expected panes.
expect(recorded).toHaveLength(1);
expect(recorded[0].vaultPath).toBe(target);
expect(recorded[0].panes).toEqual({
ancestor: 'version-1',
mine: 'bob-edit',
theirs: 'alice-edit',
});
// keep-mine landed on the remote.
const final = await writerAdapter.read(target);
expect(final).toBe('bob-edit');
});
it('disconnect → queue → reconnect: pre-queued ops drain in order, reader observes them', async () => {
// #109 final case: validates F3 (offline queue) + F6 (replay) +
// F19 (in-order delivery) by pre-populating the OfflineQueue and
// running QueueReplayer against the connected writerAdapter.
//
// The "disconnect" half of the spec (forcibly killing the SSH
// socket so SftpDataAdapter.write* enqueues internally) involves
// AdapterManager orchestration that's already covered by the
// unit-test suite for OfflineQueue + ReconnectManager. Here we
// focus on the integration assertion the unit suites can't make:
// queued ops, drained in order, surface on the reader's
// FakeFileExplorer with the same ordering the user typed.
const queueDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'rssh-queue-e2e-'));
let queue: OfflineQueue | null = null;
// F19 in-order delivery is asserted via this arrival-order log, not
// via the FakeFileExplorer.snapshot().paths shape (that snapshot is
// lex-sorted, so a/b/c paths would appear ordered even if the daemon
// delivered them out of order — a false-positive vector).
const arrivalOrder: string[] = [];
const baseStamp = `${stamp}-drain`;
const subRoot = `${subdirRel}/${baseStamp}`;
const orderRef = harnessVault.on('create', (file: unknown) => {
const p = (file as { path?: string } | null)?.path;
if (typeof p === 'string' && p.startsWith(subRoot)) arrivalOrder.push(p);
});
try {
queue = await OfflineQueue.open(queueDir);
const ops: QueuedOp[] = [
{ kind: 'mkdir', path: subRoot },
{
kind: 'writeBinary',
path: `${subRoot}/a.bin`,
contentBase64: Buffer.from('queued-a').toString('base64'),
},
{
kind: 'writeBinary',
path: `${subRoot}/b.bin`,
contentBase64: Buffer.from('queued-b').toString('base64'),
},
{
kind: 'writeBinary',
path: `${subRoot}/c.bin`,
contentBase64: Buffer.from('queued-c').toString('base64'),
},
];
for (const op of ops) await queue.enqueue(op);
expect(queue.pending()).toHaveLength(ops.length);
// "Reconnect" — drain via QueueReplayer against the live writer
// adapter. SftpDataAdapter.replayQueuedOp is the same hook the
// production reconnect path uses.
const replayer = new QueueReplayer(queue, writerAdapter);
const report = await replayer.run();
expect(report.errors).toEqual([]);
expect(report.drained).toBe(ops.length);
expect(queue.pending()).toEqual([]);
// Wait for each path to reflect on the reader so we don't race
// the assertion against an in-flight fs.changed notification.
// QueuedOp is a discriminated union; pathOf narrows each variant
// to its observable target path (rename → newPath, copy → dstPath,
// everything else → .path).
const pathOf = (o: QueuedOp): string =>
o.kind === 'rename' ? o.newPath
: o.kind === 'copy' ? o.dstPath
: o.path;
for (const op of ops) {
await fakeFE.awaitReflect(pathOf(op), 'create', PER_CASE_BUDGET_MS * 5);
}
// Falsifiable F19 assertion: arrivalOrder is the actual sequence
// of `vault.trigger('create', file)` calls observed by the reader's
// pipeline. Equality with the source `ops` order proves both
// (a) all 4 ops landed and (b) the daemon → reader pipeline
// preserved write order.
expect(arrivalOrder).toEqual(ops.map(pathOf));
} finally {
harnessVault.offref(orderRef);
await Promise.allSettled([
queue?.clear() ?? Promise.resolve(),
fs.promises.rm(queueDir, { recursive: true, force: true }),
]);
}
});
});
// ── reader pipeline (mimics main.ts handleFsChanged + applyFsChange) ───
function handleFsChangedForReader(
params: FsChangedParams,
builder: VaultModelBuilder,
readerAdapter: SftpDataAdapter,
): void {
// T4a — first thing the reader sees after the push frame decodes.
perfTracer.point('T4a', perfTracer.newCid(), {
path: params.path,
event: params.event,
subscriptionId: params.subscriptionId,
});
// applyFsChange runs async (stat for created/modified); fire-and-
// forget with internal error swallowing so a slow stat doesn't
// bubble through the RpcClient.
void (async () => {
const __t = perfTracer.begin('S.app');
try {
switch (params.event) {
case 'created': {
const stat = await readerAdapter.stat(params.path).catch(() => null);
if (!stat) return;
builder.insertOne({
path: params.path,
isDirectory: stat.type === 'folder',
ctime: stat.ctime ?? 0,
mtime: stat.mtime ?? 0,
size: stat.size ?? 0,
}, { ensureParents: true });
return;
}
case 'modified': {
const stat = await readerAdapter.stat(params.path).catch(() => null);
if (stat) {
builder.modifyOne(params.path, {
ctime: stat.ctime ?? 0, mtime: stat.mtime ?? 0, size: stat.size ?? 0,
});
} else {
builder.modifyOne(params.path);
}
return;
}
case 'deleted': {
builder.removeOne(params.path);
return;
}
case 'renamed': {
if (!params.newPath) return;
builder.renameOne(params.path, params.newPath);
return;
}
}
} finally {
perfTracer.end(__t, { event: params.event, path: params.path });
}
})();
}
// HarnessVault, HarnessTFile, HarnessTFolder, asArrayBuffer extracted
// to ./helpers/harnessVault for reuse by the self-reflect / restart /
// invariant E2E suites. See that file for the contract.