diff --git a/package.json b/package.json index fc0ddba..87ef375 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,10 @@ "types": "./dist/core/payments/index.d.ts", "default": "./dist/core/payments/index.js" }, + "./core/piece": { + "types": "./dist/core/piece/index.d.ts", + "default": "./dist/core/piece/index.js" + }, "./core/synapse": { "types": "./dist/core/synapse/index.d.ts", "default": "./dist/core/synapse/index.js" diff --git a/src/cli.ts b/src/cli.ts index 2313dd7..6956eee 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -6,6 +6,7 @@ import { addCommand } from './commands/add.js' import { dataSetCommand } from './commands/data-set.js' import { importCommand } from './commands/import.js' import { paymentsCommand } from './commands/payments.js' +import { rmCommand } from './commands/rm.js' import { serverCommand } from './commands/server.js' import { checkForUpdate, type UpdateCheckStatus } from './common/version-check.js' import { version as packageVersion } from './core/utils/version.js' @@ -24,6 +25,7 @@ program.addCommand(paymentsCommand) program.addCommand(dataSetCommand) program.addCommand(importCommand) program.addCommand(addCommand) +program.addCommand(rmCommand) // Default action - show help if no command specified program.action(() => { diff --git a/src/commands/rm.ts b/src/commands/rm.ts new file mode 100644 index 0000000..10c2923 --- /dev/null +++ b/src/commands/rm.ts @@ -0,0 +1,19 @@ +import { Command } from 'commander' +import { runRmPiece } from '../rm/index.js' +import { addAuthOptions } from '../utils/cli-options.js' + +export const rmCommand = new Command('rm') + .description('Remove a Piece from a DataSet') + .option('--piece ', 'Piece CID to remove') + .option('--data-set ', 'DataSet ID to remove the piece from') + .option('--wait-for-confirmation', 'Wait for transaction confirmation before exiting') + .action(async (options) => { + try { + await runRmPiece(options) + } catch { + // Error already displayed by clack UI in runRmPiece + process.exit(1) + } + }) + +addAuthOptions(rmCommand) diff --git a/src/core/piece/index.ts b/src/core/piece/index.ts new file mode 100644 index 0000000..0c6b7e4 --- /dev/null +++ b/src/core/piece/index.ts @@ -0,0 +1 @@ +export * from './remove-piece.js' diff --git a/src/core/piece/remove-piece.ts b/src/core/piece/remove-piece.ts new file mode 100644 index 0000000..31c02e2 --- /dev/null +++ b/src/core/piece/remove-piece.ts @@ -0,0 +1,215 @@ +/** + * Remove piece functionality + * + * This module demonstrates the pattern for removing pieces from Data Sets + * via Synapse SDK. It supports two usage patterns: + * + * 1. With dataSetId - creates a temporary storage context (CLI usage) + * 2. With existing StorageContext - reuses context (library/server usage) + * + * Progress events allow callers to track transaction submission and confirmation. + */ +import type { StorageContext, Synapse } from '@filoz/synapse-sdk' +import type { Logger } from 'pino' +import { createStorageContext } from '../synapse/index.js' +import { getErrorMessage } from '../utils/errors.js' +import type { ProgressEvent, ProgressEventHandler } from '../utils/types.js' + +/** + * Progress events emitted during piece removal + * + * These events allow callers to track the removal process: + * - submitting: Transaction is being submitted to blockchain + * - submitted: Transaction submitted successfully, txHash available + * - confirming: Waiting for transaction confirmation (if waitForConfirmation=true) + * - confirmation-failed: Confirmation wait timed out (non-fatal, tx may still succeed) + * - complete: Removal process finished + * + * Note: Errors are propagated via thrown exceptions, not events (similar to upload pattern) + */ +export type RemovePieceProgressEvents = + | ProgressEvent<'remove-piece:submitting', { pieceCid: string; dataSetId: number }> + | ProgressEvent<'remove-piece:submitted', { pieceCid: string; dataSetId: number; txHash: `0x${string}` | string }> + | ProgressEvent<'remove-piece:confirming', { pieceCid: string; dataSetId: number; txHash: `0x${string}` | string }> + | ProgressEvent< + 'remove-piece:confirmation-failed', + { pieceCid: string; dataSetId: number; txHash: `0x${string}` | string; message: string } + > + | ProgressEvent<'remove-piece:complete', { txHash: `0x${string}` | string; confirmed: boolean }> + +/** + * Number of block confirmations to wait for when waitForConfirmation=true + */ +const WAIT_CONFIRMATIONS = 1 + +/** + * Timeout in milliseconds for waiting for transaction confirmation + * Set to 2 minutes - generous default for Calibration network finality + */ +const WAIT_TIMEOUT_MS = 2 * 60 * 1000 + +/** + * Base options for piece removal + */ +interface RemovePieceOptionsBase { + /** Initialized Synapse SDK instance */ + synapse: Synapse + /** Optional progress event handler for tracking removal status */ + onProgress?: ProgressEventHandler | undefined + /** Whether to wait for transaction confirmation before returning (default: false) */ + waitForConfirmation?: boolean | undefined + /** Optional logger for tracking removal operations */ + logger?: Logger | undefined +} + +/** + * Options for removing a piece when you have a dataSetId + * + * This is the typical CLI usage pattern - you know the dataSetId and want + * to remove a piece from it. A temporary storage context will be created. + * + * Note: logger is required in this mode for storage context creation. + */ +interface RemovePieceOptionsWithDataSetId extends RemovePieceOptionsBase { + /** The Data Set ID containing the piece to remove */ + dataSetId: number + /** + * Logger instance (required for storage context creation) + * @see https://github.com/filecoin-project/filecoin-pin/issues/252 + */ + logger: Logger +} + +/** + * Options for removing a piece when you have an existing StorageContext + * + * This is useful for library/server usage where you already have a storage + * context and want to remove multiple pieces without recreating the context. + */ +interface RemovePieceOptionsWithStorage extends RemovePieceOptionsBase { + /** Existing storage context bound to a Data Set */ + storage: StorageContext +} + +/** + * Options for removing a piece from a Data Set + * + * Supports two patterns: + * - With dataSetId: Creates temporary storage context (CLI pattern) + * - With storage: Reuses existing context (library/server pattern) + */ +export type RemovePieceOptions = RemovePieceOptionsWithDataSetId | RemovePieceOptionsWithStorage + +/** + * Remove a piece from a Data Set + * + * This function demonstrates the pattern for removing pieces via Synapse SDK. + * It supports two usage patterns: + * + * Pattern 1 - With dataSetId (typical CLI usage): + * ```typescript + * const txHash = await removePiece('baga...', { + * synapse, + * dataSetId: 42, + * logger, + * onProgress: (event) => console.log(event.type), + * waitForConfirmation: true + * }) + * ``` + * + * Pattern 2 - With existing StorageContext (library/server usage): + * ```typescript + * const { storage } = await createStorageContext(synapse, logger, {...}) + * const txHash = await removePiece('baga...', { + * synapse, + * storage, + * onProgress: (event) => console.log(event.type) + * }) + * ``` + * + * @param pieceCid - The Piece CID to remove from the Data Set + * @param options - Configuration options (dataSetId or storage context) + * @returns Transaction hash of the removal operation + * @throws Error if storage context is not bound to a Data Set + */ +export async function removePiece(pieceCid: string, options: RemovePieceOptions): Promise<`0x${string}` | string> { + // Check dataSetId first + if (isRemovePieceOptionsWithDataSetId(options)) { + const { dataSetId, logger, synapse } = options + const { storage } = await createStorageContext(synapse, logger, { dataset: { useExisting: dataSetId } }) + return executeRemovePiece(pieceCid, dataSetId, storage, options) + } + + // Handle existing storage context (library/server usage) + if (isRemovePieceOptionsWithStorage(options)) { + const dataSetId = options.storage.dataSetId + if (dataSetId == null) { + throw new Error( + 'Storage context must be bound to a Data Set before removing pieces. Use createStorageContext with dataset.useExisting to bind to a Data Set.' + ) + } + return executeRemovePiece(pieceCid, dataSetId, options.storage, options) + } + + // Should never get here, but we need some clear error message if we do. + throw new Error('Invalid options: must provide either dataSetId or storage context') +} + +/** + * Type guard to check if options include dataSetId + */ +function isRemovePieceOptionsWithDataSetId(options: RemovePieceOptions): options is RemovePieceOptionsWithDataSetId { + return 'dataSetId' in options +} + +/** + * Type guard to check if options include storage context + */ +function isRemovePieceOptionsWithStorage(options: RemovePieceOptions): options is RemovePieceOptionsWithStorage { + return 'storage' in options && options.storage != null +} + +/** + * Execute the piece removal operation + * + * This internal function handles the actual removal: + * 1. Submits transaction via storageContext.deletePiece() + * 2. Optionally waits for confirmation if requested + * 3. Emits progress events throughout the process + * + * @param pieceCid - The Piece CID to remove + * @param dataSetId - The Data Set ID (for progress events) + * @param storageContext - Storage context bound to the Data Set + * @param options - Base options including callbacks and confirmation settings + * @returns Transaction hash of the removal + */ +async function executeRemovePiece( + pieceCid: string, + dataSetId: number, + storageContext: StorageContext, + options: RemovePieceOptionsBase +): Promise<`0x${string}` | string> { + const { onProgress, synapse, waitForConfirmation } = options + + onProgress?.({ type: 'remove-piece:submitting', data: { pieceCid, dataSetId } }) + const txHash = await storageContext.deletePiece(pieceCid) + onProgress?.({ type: 'remove-piece:submitted', data: { pieceCid, dataSetId, txHash } }) + + let isConfirmed = false + if (waitForConfirmation === true) { + onProgress?.({ type: 'remove-piece:confirming', data: { pieceCid, dataSetId, txHash } }) + try { + await synapse.getProvider().waitForTransaction(txHash, WAIT_CONFIRMATIONS, WAIT_TIMEOUT_MS) + isConfirmed = true + } catch (error: unknown) { + // Confirmation timeout is non-fatal - transaction may still succeed + onProgress?.({ + type: 'remove-piece:confirmation-failed', + data: { pieceCid, dataSetId, txHash, message: getErrorMessage(error) as string }, + }) + } + } + + onProgress?.({ type: 'remove-piece:complete', data: { txHash, confirmed: isConfirmed } }) + return txHash +} diff --git a/src/rm/index.ts b/src/rm/index.ts new file mode 100644 index 0000000..0c6b7e4 --- /dev/null +++ b/src/rm/index.ts @@ -0,0 +1 @@ +export * from './remove-piece.js' diff --git a/src/rm/remove-piece.ts b/src/rm/remove-piece.ts new file mode 100644 index 0000000..03e0590 --- /dev/null +++ b/src/rm/remove-piece.ts @@ -0,0 +1,137 @@ +/** + * Remove piece functionality + * + * This module handles removing pieces from Data Sets via Synapse SDK. + */ + +import pc from 'picocolors' +import pino from 'pino' +import { TELEMETRY_CLI_APP_NAME } from '../common/constants.js' +import { type RemovePieceProgressEvents, removePiece } from '../core/piece/index.js' +import { cleanupSynapseService, initializeSynapse } from '../core/synapse/index.js' +import { parseCLIAuth } from '../utils/cli-auth.js' +import { cancel, createSpinner, intro, outro } from '../utils/cli-helpers.js' +import { log } from '../utils/cli-logger.js' +import type { RmPieceOptions, RmPieceResult } from './types.js' + +/** + * Run the remove piece process + * + * @param options - Remove configuration + */ +export async function runRmPiece(options: RmPieceOptions): Promise { + intro(pc.bold('Filecoin Pin Remove')) + + const spinner = createSpinner() + + // Initialize logger (silent for CLI output) + const logger = pino({ + level: process.env.LOG_LEVEL || 'silent', + }) + + const { piece: pieceCid, dataSet } = options + + // Validate inputs + if (!pieceCid || !dataSet) { + spinner.stop(`${pc.red('✗')} Piece CID and DataSet ID are required`) + cancel('Remove cancelled') + throw new Error('Piece CID and DataSet ID are required') + } + + const dataSetId = Number(dataSet) + if (!Number.isInteger(dataSetId) || dataSetId < 0) { + spinner.stop(`${pc.red('✗')} DataSet ID must be a positive integer`) + cancel('Remove cancelled') + throw new Error('DataSet ID must be a positive integer') + } + + try { + // Initialize Synapse SDK + spinner.start('Initializing Synapse SDK...') + + const authConfig = parseCLIAuth(options) + const synapse = await initializeSynapse( + { ...authConfig, telemetry: { sentrySetTags: { appName: TELEMETRY_CLI_APP_NAME } } }, + logger + ) + const network = synapse.getNetwork() + + spinner.stop(`${pc.green('✓')} Connected to ${pc.bold(network)}`) + + log.spinnerSection('Remove Configuration', [ + pc.gray(`Piece CID: ${pieceCid}`), + pc.gray(`Data Set ID: ${dataSetId}`), + ]) + + // Track transaction details + let txHash = '' + let isConfirmed = false + + // Remove piece with progress tracking + const onProgress = (event: RemovePieceProgressEvents): void => { + switch (event.type) { + case 'remove-piece:submitting': + spinner.start('Submitting remove transaction...') + break + + case 'remove-piece:submitted': + spinner.message(`Transaction submitted: ${event.data.txHash}`) + txHash = event.data.txHash + break + + case 'remove-piece:confirming': + spinner.message('Waiting for transaction confirmation...') + break + + case 'remove-piece:confirmation-failed': + spinner.message(`${pc.yellow('⚠')} Confirmation wait timed out: ${event.data.message}`) + break + + case 'remove-piece:complete': + isConfirmed = event.data.confirmed + txHash = event.data.txHash + spinner.stop(`${pc.green('✓')} Piece removed${isConfirmed ? ' and confirmed' : ' (confirmation pending)'}`) + break + } + } + + txHash = await removePiece(pieceCid, { + synapse, + dataSetId, + logger, + onProgress, + waitForConfirmation: options.waitForConfirmation ?? false, + }) + + // Display results + + log.spinnerSection('Results', [ + pc.gray(`Transaction Hash: ${txHash}`), + pc.gray(`Status: ${isConfirmed ? 'Confirmed' : 'Pending confirmation'}`), + pc.gray(`Network: ${network}`), + ]) + + const result: RmPieceResult = { + pieceCid, + dataSetId, + transactionHash: txHash, + confirmed: isConfirmed, + } + + // Clean up WebSocket providers to allow process termination + await cleanupSynapseService() + + outro('Remove completed successfully') + + return result + } catch (error) { + spinner.stop(`${pc.red('✗')} Remove failed: ${error instanceof Error ? error.message : 'Unknown error'}`) + logger.error({ event: 'rm.failed', error }, 'Remove failed') + + cancel('Remove failed') + throw error + } finally { + // Always cleanup WebSocket providers + await cleanupSynapseService() + } +} diff --git a/src/rm/types.ts b/src/rm/types.ts new file mode 100644 index 0000000..a910136 --- /dev/null +++ b/src/rm/types.ts @@ -0,0 +1,14 @@ +import type { CLIAuthOptions } from '../utils/cli-auth.js' + +export interface RmPieceOptions extends CLIAuthOptions { + piece: string + dataSet: string + waitForConfirmation?: boolean +} + +export interface RmPieceResult { + pieceCid: string + dataSetId: number + transactionHash: string + confirmed: boolean +}