sotashimozono_obsidian-remo.../plugin/tests/RpcRemoteFsClient.test.ts

255 lines
9.6 KiB
TypeScript
Raw Permalink Normal View History

feat(plugin): RpcClient + RpcRemoteFsClient transport layer Plumbing for the daemon-backed transport that will replace SftpClient in a follow-up PR. Nothing here is wired into the runtime yet — every new module is tree-shaken out of the production bundle until the adapter switchover lands. src/transport/framing.ts - FramedDuplex wraps any Node Duplex (TCP socket, ssh2 stream) with LSP-style framing: Content-Length: <N>\r\n \r\n <N bytes of JSON> - Emits 'message', 'close', 'error'. Copies the message body out of the shared rolling buffer so later shrinks don't mutate it. - Configurable maxMessageBytes (default 16 MiB). Oversized payloads close the stream with an error. - Tolerates bare LF framing for lenient senders, matching the parallel tolerance in the Go daemon's ReadFrame. - Tests feed one byte at a time to exercise the partial-frame path. src/transport/RpcError.ts - Small typed Error carrying a code, optional data payload, and an `is(code)` helper that plays nicely with the ErrorCode constants already shipped in proto/types.ts. src/transport/RpcClient.ts - Correlates outbound call(method, params) requests with their reply by numeric id. Returns a typed Result<M> via Params<M>/Result<M>. - Demultiplexes concurrent calls. - onNotification(method, cb) dispatches server-push events (fs.changed lands in a later phase); returns a disposer. - Stream close rejects every pending call with an RpcError(-32603), so no handler hangs when the SSH tunnel drops. onClose listeners see the underlying error when there was one. - Malformed server messages are dropped rather than killing the session — the only way a pending call is never answered is via the close path, which always fires. src/adapter/RemoteFsClient.ts - Narrow interface for what SftpDataAdapter actually needs from its client: lifecycle (isAlive + onClose), read side (stat/exists/ list/readBinary), and write side (writeBinary/mkdirp/remove/ rmdir/rename/copy). Deliberately smaller than the full SFTP surface — atomic-write and parent-dir creation are the server's responsibility under the α model. src/adapter/RpcRemoteFsClient.ts - Implementation backed by RpcClient. Forwards each method to the matching daemon call, translating the wire DTOs (proto.Stat / proto.Entry / base64 string) into the existing RemoteStat / RemoteEntry / Buffer shapes so the adapter above is unchanged. - stat returns null from the daemon → thrown as an RpcError with ErrorCode.FileNotFound so the adapter sees the same "missing" signal it used to get from ssh2. - mkdirp → fs.mkdir{ recursive: true }; rmdir defaults recursive=false. Tests (vitest) - tests/framing.test.ts (7): duplex pair round-trip, multi-frame pipelining, byte-at-a-time reassembly, missing Content-Length, oversized-message rejection, bare-LF tolerance, close() refuses further writes. - tests/RpcClient.test.ts (11): id correlation, error envelope → RpcError with matching code, out-of-order concurrent calls, notification delivery + disposer, pending-call rejection on close, onClose handler with/without error, call() after close, malformed payloads ignored without hanging other calls. - tests/RpcRemoteFsClient.test.ts (11): DTO conversion for stat / list, exists / readBinary / writeBinary round-trip, mkdirp → fs.mkdir recursive, rmdir default, rename / copy param shapes, isAlive mirrors RpcClient.isClosed, stat(null) → FileNotFound. Verification - cd plugin && npx tsc --noEmit clean - cd plugin && npm test 108 / 108 pass (29 new) - cd plugin && npm run build bundle unchanged at 369 KB (transport modules are not reachable from runtime code until Phase 5-D.2 wires them in). Follow-up - Phase 5-D.2: SshTunnel (ssh2 openssh_forwardOutStreamLocal wrap) + SftpDataAdapter refactor to RemoteFsClient + runtime wiring.
2026-04-25 02:59:06 +00:00
import { describe, it, expect, vi } from 'vitest';
import { RpcRemoteFsClient } from '../src/adapter/RpcRemoteFsClient';
import { RpcError } from '../src/transport/RpcError';
import type { RpcClient } from '../src/transport/RpcClient';
/**
* RpcRemoteFsClient is a pure delegation layer: each method forwards
* to a single RpcClient.call and reshapes the DTO if needed. Testing
* it with a mocked RpcClient confirms the per-method method name,
* params shape, and DTO conversion.
*/
function mockRpc(responders: Record<string, (params: unknown) => unknown>): RpcClient {
return {
isClosed: () => false,
onClose: () => () => { /* noop */ },
call: vi.fn(async (method: string, params: unknown) => {
const handler = responders[method];
if (!handler) throw new RpcError(-32601, `no fake for method ${method}`);
return handler(params);
}),
} as unknown as RpcClient;
}
describe('RpcRemoteFsClient', () => {
it('stat reshapes proto.Stat into RemoteStat', async () => {
const client = new RpcRemoteFsClient(mockRpc({
'fs.stat': (p) => {
expect(p).toEqual({ path: 'note.md' });
return { type: 'file', mtime: 123, size: 4, mode: 0o100644 };
},
}));
const s = await client.stat('note.md');
expect(s.isFile).toBe(true);
expect(s.isDirectory).toBe(false);
expect(s.mtime).toBe(123);
expect(s.size).toBe(4);
});
it('stat throws FileNotFound when the server returns null', async () => {
const client = new RpcRemoteFsClient(mockRpc({
'fs.stat': () => null,
}));
await expect(client.stat('gone.md')).rejects.toBeInstanceOf(RpcError);
try {
await client.stat('gone.md');
} catch (e) {
expect((e as RpcError).code).toBe(-32010);
}
});
it('exists unwraps the boolean', async () => {
const client = new RpcRemoteFsClient(mockRpc({
'fs.exists': () => ({ exists: false }),
}));
expect(await client.exists('x')).toBe(false);
});
it('list reshapes Entry[] into RemoteEntry[]', async () => {
const client = new RpcRemoteFsClient(mockRpc({
'fs.list': () => ({
entries: [
{ name: 'a.md', type: 'file', mtime: 1, size: 2 },
{ name: 'docs', type: 'folder', mtime: 3, size: 0 },
{ name: 'link.md', type: 'symlink', mtime: 5, size: 0 },
],
}),
}));
const entries = await client.list('');
expect(entries.length).toBe(3);
expect(entries[0].isFile).toBe(true);
expect(entries[1].isDirectory).toBe(true);
expect(entries[2].isSymbolicLink).toBe(true);
});
it('readBinary base64-decodes the server payload', async () => {
const client = new RpcRemoteFsClient(mockRpc({
'fs.readBinary': () => ({
contentBase64: Buffer.from([10, 20, 30]).toString('base64'),
mtime: 0,
size: 3,
}),
}));
const buf = await client.readBinary('x.bin');
expect([...buf]).toEqual([10, 20, 30]);
});
it('writeBinary base64-encodes the payload', async () => {
const calls: Array<{ method: string; params: unknown }> = [];
const client = new RpcRemoteFsClient({
isClosed: () => false,
onClose: () => () => { /* noop */ },
call: vi.fn(async (method: string, params: unknown) => {
calls.push({ method, params });
if (method === 'fs.writeBinary') return { mtime: 1 };
throw new RpcError(-32601, method);
}),
} as unknown as RpcClient);
await client.writeBinary('x.bin', Buffer.from([1, 2, 3]));
expect(calls.length).toBe(1);
expect(calls[0].method).toBe('fs.writeBinary');
const params = calls[0].params as { path: string; contentBase64: string };
expect(params.path).toBe('x.bin');
expect(Buffer.from(params.contentBase64, 'base64').toString('hex')).toBe('010203');
feat(plugin): write-conflict detection via expectedMtime (Tier 2-C) Two clients on the same remote vault could silently overwrite each other's edits: each writes through `fs.write` with no precondition, last-writer-wins. The proto already had `expectedMtime` in the write params (rejected with PreconditionFailed = -32020 when the remote mtime moved), but the plugin never set it. - `RemoteFsClient.writeBinary` gains an optional `expectedMtime` arg. `RpcRemoteFsClient` forwards it into the proto call; `SftpRemoteFsClient` ignores it (SFTP can't enforce a precondition atomically — conflict detection is RPC-only, by design). - `SftpDataAdapter.writeBuffer` looks up `readCache.peek(remote)` before a write. When there's a hit, the cached mtime goes through as `expectedMtime` so a concurrent edit on the remote rejects the write. First-touch writes (no cache entry) skip the precondition, avoiding false positives on files we never read. - New optional `onWriteConflict?: (vaultPath) => Promise<boolean>` on the adapter. On PreconditionFailed it asks the callback; true retries without `expectedMtime` (overwrite), false re-throws. - `WriteConflictModal` (new): two-button Obsidian Modal that prompts "Overwrite remote / Cancel". Closing without picking resolves to false so the promise always settles. - main.ts wires the modal as the adapter's onWriteConflict in `patchAdapter`. The PreconditionFailed check is duck-typed on `e.code === -32020` so the adapter doesn't have to import RpcError from the transport layer. Tests: 7 new SftpDataAdapter cases (cached mtime forwarded, no mtime forwarded for first-touch, overwrite retries without precondition, cancel re-throws, no callback re-throws, non- precondition error bypasses callback) + 1 RpcRemoteFsClient case for the new arg threading. 247 total green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 01:22:48 +00:00
expect((params as { expectedMtime?: number }).expectedMtime).toBeUndefined();
});
it('writeBinary forwards expectedMtime when provided', async () => {
const calls: Array<{ method: string; params: unknown }> = [];
const client = new RpcRemoteFsClient({
isClosed: () => false,
onClose: () => () => { /* noop */ },
call: vi.fn(async (method: string, params: unknown) => {
calls.push({ method, params });
if (method === 'fs.writeBinary') return { mtime: 2 };
throw new RpcError(-32601, method);
}),
} as unknown as RpcClient);
await client.writeBinary('x.bin', Buffer.from([1]), 1234);
const params = calls[0].params as { expectedMtime?: number };
expect(params.expectedMtime).toBe(1234);
feat(plugin): RpcClient + RpcRemoteFsClient transport layer Plumbing for the daemon-backed transport that will replace SftpClient in a follow-up PR. Nothing here is wired into the runtime yet — every new module is tree-shaken out of the production bundle until the adapter switchover lands. src/transport/framing.ts - FramedDuplex wraps any Node Duplex (TCP socket, ssh2 stream) with LSP-style framing: Content-Length: <N>\r\n \r\n <N bytes of JSON> - Emits 'message', 'close', 'error'. Copies the message body out of the shared rolling buffer so later shrinks don't mutate it. - Configurable maxMessageBytes (default 16 MiB). Oversized payloads close the stream with an error. - Tolerates bare LF framing for lenient senders, matching the parallel tolerance in the Go daemon's ReadFrame. - Tests feed one byte at a time to exercise the partial-frame path. src/transport/RpcError.ts - Small typed Error carrying a code, optional data payload, and an `is(code)` helper that plays nicely with the ErrorCode constants already shipped in proto/types.ts. src/transport/RpcClient.ts - Correlates outbound call(method, params) requests with their reply by numeric id. Returns a typed Result<M> via Params<M>/Result<M>. - Demultiplexes concurrent calls. - onNotification(method, cb) dispatches server-push events (fs.changed lands in a later phase); returns a disposer. - Stream close rejects every pending call with an RpcError(-32603), so no handler hangs when the SSH tunnel drops. onClose listeners see the underlying error when there was one. - Malformed server messages are dropped rather than killing the session — the only way a pending call is never answered is via the close path, which always fires. src/adapter/RemoteFsClient.ts - Narrow interface for what SftpDataAdapter actually needs from its client: lifecycle (isAlive + onClose), read side (stat/exists/ list/readBinary), and write side (writeBinary/mkdirp/remove/ rmdir/rename/copy). Deliberately smaller than the full SFTP surface — atomic-write and parent-dir creation are the server's responsibility under the α model. src/adapter/RpcRemoteFsClient.ts - Implementation backed by RpcClient. Forwards each method to the matching daemon call, translating the wire DTOs (proto.Stat / proto.Entry / base64 string) into the existing RemoteStat / RemoteEntry / Buffer shapes so the adapter above is unchanged. - stat returns null from the daemon → thrown as an RpcError with ErrorCode.FileNotFound so the adapter sees the same "missing" signal it used to get from ssh2. - mkdirp → fs.mkdir{ recursive: true }; rmdir defaults recursive=false. Tests (vitest) - tests/framing.test.ts (7): duplex pair round-trip, multi-frame pipelining, byte-at-a-time reassembly, missing Content-Length, oversized-message rejection, bare-LF tolerance, close() refuses further writes. - tests/RpcClient.test.ts (11): id correlation, error envelope → RpcError with matching code, out-of-order concurrent calls, notification delivery + disposer, pending-call rejection on close, onClose handler with/without error, call() after close, malformed payloads ignored without hanging other calls. - tests/RpcRemoteFsClient.test.ts (11): DTO conversion for stat / list, exists / readBinary / writeBinary round-trip, mkdirp → fs.mkdir recursive, rmdir default, rename / copy param shapes, isAlive mirrors RpcClient.isClosed, stat(null) → FileNotFound. Verification - cd plugin && npx tsc --noEmit clean - cd plugin && npm test 108 / 108 pass (29 new) - cd plugin && npm run build bundle unchanged at 369 KB (transport modules are not reachable from runtime code until Phase 5-D.2 wires them in). Follow-up - Phase 5-D.2: SshTunnel (ssh2 openssh_forwardOutStreamLocal wrap) + SftpDataAdapter refactor to RemoteFsClient + runtime wiring.
2026-04-25 02:59:06 +00:00
});
it('mkdirp maps to fs.mkdir with recursive=true', async () => {
const spy = vi.fn(async () => ({}));
const client = new RpcRemoteFsClient({
isClosed: () => false,
onClose: () => () => { /* noop */ },
call: spy,
} as unknown as RpcClient);
await client.mkdirp('docs/sub');
expect(spy).toHaveBeenCalledWith('fs.mkdir', { path: 'docs/sub', recursive: true });
});
it('remove maps to fs.remove', async () => {
const spy = vi.fn(async () => ({}));
const client = new RpcRemoteFsClient({
isClosed: () => false,
onClose: () => () => { /* noop */ },
call: spy,
} as unknown as RpcClient);
await client.remove('a.md');
expect(spy).toHaveBeenCalledWith('fs.remove', { path: 'a.md' });
});
it('rmdir defaults recursive to false', async () => {
const spy = vi.fn(async () => ({}));
const client = new RpcRemoteFsClient({
isClosed: () => false,
onClose: () => () => { /* noop */ },
call: spy,
} as unknown as RpcClient);
await client.rmdir('empty-dir');
expect(spy).toHaveBeenCalledWith('fs.rmdir', { path: 'empty-dir', recursive: false });
});
it('rename forwards old/new paths', async () => {
const spy = vi.fn(async () => ({ mtime: 42 }));
const client = new RpcRemoteFsClient({
isClosed: () => false,
onClose: () => () => { /* noop */ },
call: spy,
} as unknown as RpcClient);
await client.rename('old.md', 'new.md');
expect(spy).toHaveBeenCalledWith('fs.rename', { oldPath: 'old.md', newPath: 'new.md' });
});
it('copy forwards src/dest paths', async () => {
const spy = vi.fn(async () => ({ mtime: 42 }));
const client = new RpcRemoteFsClient({
isClosed: () => false,
onClose: () => () => { /* noop */ },
call: spy,
} as unknown as RpcClient);
await client.copy('a.md', 'b.md');
expect(spy).toHaveBeenCalledWith('fs.copy', { srcPath: 'a.md', destPath: 'b.md' });
});
it('isAlive mirrors the RpcClient closed state', () => {
const fake = { isClosed: vi.fn(() => false), onClose: () => () => { /* noop */ }, call: vi.fn() };
const client = new RpcRemoteFsClient(fake as unknown as RpcClient);
expect(client.isAlive()).toBe(true);
fake.isClosed = vi.fn(() => true);
expect(client.isAlive()).toBe(false);
});
feat(rpc): fs.readBinaryRange + ResourceBridge fast path (#134, 0.4.83) True partial reads in the daemon protocol so the bridge no longer loads the whole file into memory just to slice — meaningful for streaming video, large PDFs, and constrained-memory remote hosts. Server side - proto: ReadBinaryRangeParams { path, offset, length, expectedMtime? } + ReadBinaryRangeResult { contentBase64, mtime, size }. Size always reports the TOTAL on-disk file size so HTTP Content-Range responses can build `bytes start-end/<total>` without a separate stat round-trip. - handlers: FsReadBinaryRange uses os.File.ReadAt (pread on POSIX, no full-file load). Reads past EOF clamp silently. Negative offset or length are rejected as InvalidParams. expectedMtime != 0 failure surfaces as PreconditionFailed so range-aware callers can detect a mid-read edit and restart from offset 0. - registration in cmd/main.go so server.info advertises the method in capabilities (existing dispatcher.Methods() machinery auto-discovers from disp.Handle calls). - 9 new unit tests (happy / clamp / past-EOF / mtime mismatch / mtime match / negative offset / negative length / directory / outside-vault / not-found). Plugin side - proto/types.ts: TS mirror of params + result, MethodMap row, MethodName union arm. - RpcRemoteFsClient.readBinaryRange(path, offset, length, expectedMtime?) — reshapes contentBase64 → Buffer, returns { data, mtime, size }. - ResourceBridge: new optional FetchBinaryRangeFn callback in start(); when present AND the request carries an explicit `bytes=N-M` Range header, bridge issues a single fs.readBinaryRange RPC instead of fetching+slicing the whole file. `bytes=N-` / `bytes=-N` (which need total size to parse) still fall through to the legacy full-file path. New parseExplicitByteRange helper is exported and unit-tested. On any range-fetcher error the bridge logs and falls back transparently. - main.ts: makeBinaryRangeFetcherIfSupported() mirrors the existing thumbnail capability gate — wires the callback only when conn.info.capabilities includes 'fs.readBinaryRange'. Older daemons keep working unchanged via the legacy slice path. - 11 new unit tests covering the fast-path callback wiring, the parser, fall-through cases (no callback / non-explicit form / fetcher throws / no Range header), 416 on past-EOF start, and daemon-side EOF clamping. Backwards compatibility - ResourceBridge.start signature gained an optional 3rd param; existing 1-arg and 2-arg call sites compile unchanged. - Older daemons that don't register fs.readBinaryRange simply don't advertise the capability → makeBinaryRangeFetcherIfSupported returns null → bridge stays on the existing full-file path. Closes #134 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 01:10:15 +00:00
// ─── readBinaryRange (#134) ───────────────────────────────────────────
it('readBinaryRange forwards offset/length and decodes the slice', async () => {
const calls: Array<{ method: string; params: unknown }> = [];
const slice = Buffer.from([100, 101, 102, 103]);
const client = new RpcRemoteFsClient({
isClosed: () => false,
onClose: () => () => { /* noop */ },
call: vi.fn(async (method: string, params: unknown) => {
calls.push({ method, params });
if (method === 'fs.readBinaryRange') {
return { contentBase64: slice.toString('base64'), mtime: 12345, size: 1024 };
}
throw new RpcError(-32601, method);
}),
} as unknown as RpcClient);
const result = await client.readBinaryRange('big.bin', 100, 4);
expect(calls.length).toBe(1);
expect(calls[0].method).toBe('fs.readBinaryRange');
expect(calls[0].params).toEqual({ path: 'big.bin', offset: 100, length: 4 });
expect([...result.data]).toEqual([100, 101, 102, 103]);
expect(result.mtime).toBe(12345);
expect(result.size).toBe(1024); // total file size, not slice length
});
it('readBinaryRange forwards expectedMtime when provided', async () => {
const calls: Array<{ method: string; params: unknown }> = [];
const client = new RpcRemoteFsClient({
isClosed: () => false,
onClose: () => () => { /* noop */ },
call: vi.fn(async (method: string, params: unknown) => {
calls.push({ method, params });
return { contentBase64: '', mtime: 999, size: 50 };
}),
} as unknown as RpcClient);
await client.readBinaryRange('x.bin', 0, 10, 999);
const params = calls[0].params as { expectedMtime?: number };
expect(params.expectedMtime).toBe(999);
});
it('readBinaryRange omits expectedMtime when not provided', async () => {
const calls: Array<{ method: string; params: unknown }> = [];
const client = new RpcRemoteFsClient({
isClosed: () => false,
onClose: () => () => { /* noop */ },
call: vi.fn(async (method: string, params: unknown) => {
calls.push({ method, params });
return { contentBase64: '', mtime: 0, size: 0 };
}),
} as unknown as RpcClient);
await client.readBinaryRange('x.bin', 0, 10);
const params = calls[0].params as Record<string, unknown>;
expect('expectedMtime' in params).toBe(false);
});
it('readBinaryRange returns an empty Buffer when the slice is empty (past EOF)', async () => {
const client = new RpcRemoteFsClient(mockRpc({
'fs.readBinaryRange': () => ({ contentBase64: '', mtime: 0, size: 50 }),
}));
const result = await client.readBinaryRange('x.bin', 100, 10);
expect(result.data.byteLength).toBe(0);
expect(result.size).toBe(50);
});
feat(plugin): RpcClient + RpcRemoteFsClient transport layer Plumbing for the daemon-backed transport that will replace SftpClient in a follow-up PR. Nothing here is wired into the runtime yet — every new module is tree-shaken out of the production bundle until the adapter switchover lands. src/transport/framing.ts - FramedDuplex wraps any Node Duplex (TCP socket, ssh2 stream) with LSP-style framing: Content-Length: <N>\r\n \r\n <N bytes of JSON> - Emits 'message', 'close', 'error'. Copies the message body out of the shared rolling buffer so later shrinks don't mutate it. - Configurable maxMessageBytes (default 16 MiB). Oversized payloads close the stream with an error. - Tolerates bare LF framing for lenient senders, matching the parallel tolerance in the Go daemon's ReadFrame. - Tests feed one byte at a time to exercise the partial-frame path. src/transport/RpcError.ts - Small typed Error carrying a code, optional data payload, and an `is(code)` helper that plays nicely with the ErrorCode constants already shipped in proto/types.ts. src/transport/RpcClient.ts - Correlates outbound call(method, params) requests with their reply by numeric id. Returns a typed Result<M> via Params<M>/Result<M>. - Demultiplexes concurrent calls. - onNotification(method, cb) dispatches server-push events (fs.changed lands in a later phase); returns a disposer. - Stream close rejects every pending call with an RpcError(-32603), so no handler hangs when the SSH tunnel drops. onClose listeners see the underlying error when there was one. - Malformed server messages are dropped rather than killing the session — the only way a pending call is never answered is via the close path, which always fires. src/adapter/RemoteFsClient.ts - Narrow interface for what SftpDataAdapter actually needs from its client: lifecycle (isAlive + onClose), read side (stat/exists/ list/readBinary), and write side (writeBinary/mkdirp/remove/ rmdir/rename/copy). Deliberately smaller than the full SFTP surface — atomic-write and parent-dir creation are the server's responsibility under the α model. src/adapter/RpcRemoteFsClient.ts - Implementation backed by RpcClient. Forwards each method to the matching daemon call, translating the wire DTOs (proto.Stat / proto.Entry / base64 string) into the existing RemoteStat / RemoteEntry / Buffer shapes so the adapter above is unchanged. - stat returns null from the daemon → thrown as an RpcError with ErrorCode.FileNotFound so the adapter sees the same "missing" signal it used to get from ssh2. - mkdirp → fs.mkdir{ recursive: true }; rmdir defaults recursive=false. Tests (vitest) - tests/framing.test.ts (7): duplex pair round-trip, multi-frame pipelining, byte-at-a-time reassembly, missing Content-Length, oversized-message rejection, bare-LF tolerance, close() refuses further writes. - tests/RpcClient.test.ts (11): id correlation, error envelope → RpcError with matching code, out-of-order concurrent calls, notification delivery + disposer, pending-call rejection on close, onClose handler with/without error, call() after close, malformed payloads ignored without hanging other calls. - tests/RpcRemoteFsClient.test.ts (11): DTO conversion for stat / list, exists / readBinary / writeBinary round-trip, mkdirp → fs.mkdir recursive, rmdir default, rename / copy param shapes, isAlive mirrors RpcClient.isClosed, stat(null) → FileNotFound. Verification - cd plugin && npx tsc --noEmit clean - cd plugin && npm test 108 / 108 pass (29 new) - cd plugin && npm run build bundle unchanged at 369 KB (transport modules are not reachable from runtime code until Phase 5-D.2 wires them in). Follow-up - Phase 5-D.2: SshTunnel (ssh2 openssh_forwardOutStreamLocal wrap) + SftpDataAdapter refactor to RemoteFsClient + runtime wiring.
2026-04-25 02:59:06 +00:00
});