diff --git a/.changeset/rclone-exact-file-deletion.md b/.changeset/rclone-exact-file-deletion.md new file mode 100644 index 00000000..fe0ba13d --- /dev/null +++ b/.changeset/rclone-exact-file-deletion.md @@ -0,0 +1,6 @@ +--- +"@luzzle/web.worker": patch +--- + +Add an exact-file remote deletion helper using the existing rclone runner, +logging and configured flags. diff --git a/packages/web/worker/src/services/rclone.test.ts b/packages/web/worker/src/services/rclone.test.ts index b95fd359..b98c6844 100644 --- a/packages/web/worker/src/services/rclone.test.ts +++ b/packages/web/worker/src/services/rclone.test.ts @@ -120,6 +120,100 @@ describe('RcloneClient flags passthrough', () => { }) }) +describe('RcloneClient deleteFile', () => { + const options = { + remote: 'cdn', + remotePath: 'published/books/key/cover #1.png', + configPath: '/config/rclone.conf', + } + + beforeEach(() => vi.clearAllMocks()) + + it.each([{ flags: undefined }, { flags: [] }])( + 'deletes one exact remote file with flags=$flags', + async ({ flags }) => { + const spawnMock = await mockSpawnSuccess() + const client = new RcloneClient(makeLogger()) + + await client.deleteFile({ ...options, flags }) + + expect(spawnMock).toHaveBeenCalledExactlyOnceWith( + 'rclone', + [ + 'deletefile', + 'cdn:published/books/key/cover #1.png', + '--config', + '/config/rclone.conf', + '--verbose', + ], + { stdio: ['ignore', 'pipe', 'pipe'] } + ) + } + ) + + it('passes configured flags through unchanged', async () => { + const spawnMock = await mockSpawnSuccess() + const logger = makeLogger() + const client = new RcloneClient(logger) + const flags = ['--s3-endpoint', 'https://objects.example.test', '--dry-run'] + + await client.deleteFile({ ...options, flags }) + + expect(spawnMock).toHaveBeenCalledExactlyOnceWith( + 'rclone', + [ + 'deletefile', + 'cdn:published/books/key/cover #1.png', + '--config', + '/config/rclone.conf', + '--verbose', + ...flags, + ], + { stdio: ['ignore', 'pipe', 'pipe'] } + ) + expect(logger.info).toHaveBeenCalledWith('rclone deletefile starting', { + remote: 'cdn:published/books/key/cover #1.png', + flags, + }) + }) + + it('reports a failed deletion without retrying', async () => { + const { spawn } = await import('child_process') + const child = new EventEmitter() as any + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + vi.mocked(spawn).mockReturnValue(child) + const logger = makeLogger() + const client = new RcloneClient(logger) + + const deletion = client.deleteFile(options) + const rejection = expect(deletion).rejects.toThrow('rclone exited with code 1') + child.stderr.emit('data', Buffer.from('remote rejected deletion\n')) + child.emit('close', 1) + + await rejection + expect(logger.stderr).toHaveBeenCalledWith('remote rejected deletion') + expect(spawn).toHaveBeenCalledTimes(1) + }) + + it('propagates a process error without retrying', async () => { + const { spawn } = await import('child_process') + const child = new EventEmitter() as any + child.stdout = new EventEmitter() + child.stderr = new EventEmitter() + vi.mocked(spawn).mockReturnValue(child) + const client = new RcloneClient(makeLogger()) + const error = new Error('rclone is unavailable') + + const deletion = client.deleteFile(options) + const rejection = expect(deletion).rejects.toBe(error) + child.emit('error', error) + + await rejection + expect(spawn).toHaveBeenCalledTimes(1) + }) +}) + describe('RcloneClient checkConnectivity', () => { beforeEach(() => vi.clearAllMocks()) diff --git a/packages/web/worker/src/services/rclone.ts b/packages/web/worker/src/services/rclone.ts index 0b89f637..00c5cc7f 100644 --- a/packages/web/worker/src/services/rclone.ts +++ b/packages/web/worker/src/services/rclone.ts @@ -27,6 +27,13 @@ export interface RcloneCopyOptions { flags?: string[] } +export interface RcloneDeleteFileOptions { + remote: string + remotePath: string + configPath: string + flags?: string[] +} + export interface RcloneCheckOptions { remote: string remotePath: string @@ -124,6 +131,22 @@ export class RcloneClient { await this.run('rclone', args) } + async deleteFile(options: RcloneDeleteFileOptions): Promise { + const target = `${options.remote}:${options.remotePath}` + const args = ['deletefile', target, '--config', options.configPath, '--verbose'] + + if (options.flags?.length) { + args.push(...options.flags) + } + + this.logger.info('rclone deletefile starting', { + remote: target, + flags: options.flags, + }) + + await this.run('rclone', args) + } + checkConnectivity(options: RcloneCheckOptions): Promise { const { remote, remotePath, configPath, timeoutMs = 10000 } = options const args = ['lsd', `${remote}:${remotePath}`, '--config', configPath, '--max-depth', '1']