filecoin-pin
Version:
Bridge IPFS content to Filecoin Onchain Cloud using familiar tools
528 lines (468 loc) • 16 kB
text/typescript
import { EventEmitter } from 'node:events'
import { readFile, unlink } from 'node:fs/promises'
import { join } from 'node:path'
import type { Helia } from 'helia'
import type { CID } from 'multiformats/cid'
import type { Logger } from 'pino'
import type { CARBlockstoreStats, CARWritingBlockstore } from './core/car/index.js'
import type { Config, SynapseService } from './core/synapse/index.js'
import { uploadToSynapse } from './core/upload/index.js'
import { createPinningHeliaNode } from './create-pinning-helia.js'
export interface PinningServiceUser {
id: string
name: string
}
export interface PinOptions {
name?: string
origins?: string[]
meta?: Record<string, string>
}
export interface StoredPinStatus {
id: string
status: 'queued' | 'pinning' | 'pinned' | 'failed'
created: number
pin: {
cid: string
name?: string
origins?: string[]
meta?: Record<string, string>
}
info?: Record<string, string>
}
export interface FilecoinPinMetadata {
carFilePath: string
carStats: CARBlockstoreStats
pinStarted: number
pinCompleted?: number
synapsePieceCid?: string
synapsePieceId?: number
synapseDataSetId?: string
}
export interface FilecoinStoredPinStatus extends StoredPinStatus {
filecoin?: FilecoinPinMetadata
}
export interface FilecoinPinStoreInit {
config: Config
logger: Logger
synapseService: SynapseService
}
/**
* Filecoin-specific pin store that creates CAR files for each pin operation
*/
export class FilecoinPinStore extends EventEmitter {
private readonly config: Config
private readonly logger: Logger
private readonly synapseService: SynapseService
private readonly pins = new Map<string, FilecoinStoredPinStatus>()
private readonly activePins = new Map<
string,
{
helia: Helia
blockstore: CARWritingBlockstore
metadata: FilecoinPinMetadata
}
>()
private pinCounter = 0
constructor(init: FilecoinPinStoreInit) {
super()
this.config = init.config
this.logger = init.logger
this.synapseService = init.synapseService
}
async start(): Promise<void> {
this.logger.info('Filecoin pin store started')
}
async stop(): Promise<void> {
// Clean up any active pins
for (const [pinId, { helia, blockstore }] of this.activePins.entries()) {
try {
await blockstore.cleanup()
await helia.stop()
} catch (error) {
this.logger.warn({ pinId, error }, 'Error cleaning up active pin during shutdown')
}
}
this.activePins.clear()
this.logger.info('Filecoin pin store stopped')
}
async pin(user: PinningServiceUser, cid: CID, options: PinOptions = {}): Promise<FilecoinStoredPinStatus> {
const pinId = `pin-${Date.now()}-${++this.pinCounter}`
const pinStarted = Date.now()
// Generate CAR file path
const carFileName = `${cid.toString()}-${pinStarted}.car`
const carFilePath = join(this.config.carStoragePath, carFileName)
this.logger.info(
{
userId: user.id,
pinId,
cid: cid.toString(),
carFilePath,
name: options.name,
},
'Starting Filecoin pin operation'
)
// Create initial pin record
const pinStatus: FilecoinStoredPinStatus = {
id: pinId,
status: 'queued',
created: pinStarted,
pin: {
cid: cid.toString(),
...(options.name != null && { name: options.name }),
...(options.origins != null && { origins: options.origins }),
...(options.meta != null && { meta: options.meta }),
},
filecoin: {
carFilePath,
carStats: {
blocksWritten: 0,
missingBlocks: new Set(),
totalSize: 0,
startTime: pinStarted,
finalized: false,
},
pinStarted,
},
info: {
car_file_path: carFilePath,
blocks_written: '0',
total_size: '0',
status: 'initializing',
},
}
// Store the pin
this.pins.set(pinId, pinStatus)
// Start the actual pinning process in the background after a small delay
setTimeout(() => {
this.logger.debug({ pinId }, 'setTimeout callback executing')
this._processPinInBackground(pinId, user, cid)
.then(() => {
this.logger.debug({ pinId }, 'Background processing completed')
})
.catch((error) => {
this.logger.error(
{
pinId,
userId: user.id,
cid: cid.toString(),
error: error instanceof Error ? (error.stack ?? error.message) : String(error),
},
'Background pin processing failed'
)
// Update pin status to failed
const failedPin = this.pins.get(pinId)
if (failedPin != null) {
failedPin.status = 'failed'
failedPin.info = {
...failedPin.info,
error: error.message,
status: 'failed',
}
this.pins.set(pinId, failedPin)
}
})
}, 100) // Small delay to ensure pin starts in 'queued' state
return pinStatus
}
private async _processPinInBackground(pinId: string, user: PinningServiceUser, cid: CID): Promise<void> {
this.logger.debug({ pinId, cid: cid.toString() }, 'Entered _processPinInBackground')
const pinStatus = this.pins.get(pinId)
if (pinStatus == null || pinStatus.filecoin == null) {
this.logger.error({ pinId }, 'Pin not found in _processPinInBackground')
throw new Error(`Pin ${pinId} not found`)
}
try {
this.logger.info({ pinId, cid: cid.toString() }, 'Starting background pin processing')
// Update status to pinning
pinStatus.status = 'pinning'
if (pinStatus.info != null) {
pinStatus.info.status = 'pinning'
}
this.pins.set(pinId, pinStatus)
// Create a single Helia node with CAR blockstore for this specific pin
this.logger.debug({ pinId, cid: cid.toString() }, 'Creating pinning Helia node')
const { helia, blockstore } = await createPinningHeliaNode({
config: this.config,
logger: this.logger,
rootCID: cid,
outputPath: pinStatus.filecoin.carFilePath,
origins: pinStatus.pin.origins ?? [],
})
this.logger.debug({ pinId, cid: cid.toString() }, 'Pinning Helia node created')
// Store active pin info
this.activePins.set(pinId, {
helia,
blockstore,
metadata: pinStatus.filecoin,
})
// Set up event handlers for monitoring
blockstore.on('block:stored', (data) => {
this.emit('pin:block:stored', {
pinId,
userId: user.id,
cid: data.cid,
size: data.size,
})
// Update pin status
const currentPin = this.pins.get(pinId)
if (currentPin?.filecoin != null) {
currentPin.filecoin.carStats = blockstore.getStats()
currentPin.info = {
...currentPin.info,
blocks_written: currentPin.filecoin.carStats.blocksWritten.toString(),
total_size: currentPin.filecoin.carStats.totalSize.toString(),
}
this.pins.set(pinId, currentPin)
}
})
blockstore.on('block:missing', (data) => {
this.emit('pin:block:missing', {
pinId,
userId: user.id,
cid: data.cid,
})
})
// Fetch and pin the DAG
this.logger.debug({ pinId, cid: cid.toString() }, 'Starting DAG pinning')
try {
// Use Helia's pin system to walk the full DAG
// This will fetch all blocks recursively via Bitswap
this.logger.debug({ pinId, cid: cid.toString() }, 'Pinning content via Helia')
for await (const pinnedCid of helia.pins.add(cid)) {
this.logger.debug(
{
pinId,
cid: cid.toString(),
pinnedCid: pinnedCid.toString(),
},
'Block pinned during DAG walk'
)
}
this.logger.info({ pinId, cid: cid.toString() }, 'Content fully pinned')
} catch (error) {
this.logger.warn(
{
pinId,
cid: cid.toString(),
error: error instanceof Error ? error.message : String(error),
},
'Failed to pin content - some blocks may be missing'
)
// Don't throw - we'll finalize the CAR with whatever blocks we got
}
// Finalize the CAR file
const finalStats = await blockstore.finalize()
this.logger.info(
{
pinId,
cid: cid.toString(),
blocksWritten: finalStats.blocksWritten,
totalSize: finalStats.totalSize,
missingBlocks: finalStats.missingBlocks.size,
},
'CAR file finalized'
)
// Store on Filecoin using Synapse SDK
// This section demonstrates the integration pattern:
// 1. Prepare the data (CAR file) for upload
// 2. Use Synapse's storage.upload() with callbacks for monitoring
// 3. Track the returned piece information in application state
// 4. Handle errors gracefully with proper cleanup
try {
// Read the CAR file (streaming not yet supported in Synapse)
// TODO: When Synapse supports streaming, this could be optimized
const carData = await readFile(pinStatus.filecoin.carFilePath)
// Upload using shared function with pinId as context and IPFS root CID metadata
const uploadResult = await uploadToSynapse(this.synapseService, carData, cid, this.logger, {
contextId: pinId,
})
// Store Synapse metadata
pinStatus.filecoin.synapsePieceCid = uploadResult.pieceCid
if (uploadResult.pieceId !== undefined) {
pinStatus.filecoin.synapsePieceId = uploadResult.pieceId
}
pinStatus.filecoin.synapseDataSetId = uploadResult.dataSetId
// Add to info for API response
pinStatus.info = {
...pinStatus.info,
synapse_piece_cid: uploadResult.pieceCid,
synapse_piece_id: (uploadResult.pieceId ?? 0).toString(),
synapse_data_set_id: uploadResult.dataSetId,
}
} catch (error) {
// Rollback on Synapse failure
this.logger.error(
{
event: 'synapse.upload.failed',
pinId,
error,
},
'Failed to upload to Filecoin with Synapse, rolling back'
)
// Clean up the CAR file
try {
await blockstore.cleanup()
await unlink(pinStatus.filecoin.carFilePath)
this.logger.info(
{ pinId, carFilePath: pinStatus.filecoin.carFilePath },
'Deleted CAR file after Synapse failure'
)
} catch (cleanupError) {
this.logger.warn({ pinId, error: cleanupError }, 'Failed to clean up CAR file')
}
// Re-throw to mark pin as failed
throw new Error(`Synapse upload failed: ${error instanceof Error ? error.message : 'Unknown error'}`)
}
// Update pin status to completed
pinStatus.status = 'pinned'
pinStatus.filecoin.carStats = finalStats
pinStatus.filecoin.pinCompleted = Date.now()
pinStatus.info = {
...pinStatus.info,
status: 'pinned',
blocks_written: finalStats.blocksWritten.toString(),
total_size: finalStats.totalSize.toString(),
missing_blocks: finalStats.missingBlocks.size.toString(),
pin_duration: (pinStatus.filecoin.pinCompleted - pinStatus.filecoin.pinStarted).toString(),
}
this.pins.set(pinId, pinStatus)
// Emit completion event
this.emit('pin:car:completed', {
pinId,
userId: user.id,
cid,
stats: finalStats,
carFilePath: pinStatus.filecoin.carFilePath,
})
this.logger.info({ pinId, cid: cid.toString() }, 'Pin processing completed successfully')
} catch (error) {
this.logger.error({ pinId, cid: cid.toString(), error }, 'Pin processing failed')
// Update pin status to failed
pinStatus.status = 'failed'
pinStatus.info = {
...pinStatus.info,
status: 'failed',
error: error instanceof Error ? error.message : 'Unknown error',
}
this.pins.set(pinId, pinStatus)
this.emit('pin:failed', {
pinId,
userId: user.id,
cid,
error,
})
} finally {
// Clean up active pin
const activePin = this.activePins.get(pinId)
if (activePin != null) {
await activePin.helia.stop()
this.activePins.delete(pinId)
}
}
}
async get(_user: PinningServiceUser, id: string): Promise<FilecoinStoredPinStatus | undefined> {
return this.pins.get(id)
}
async update(
_user: PinningServiceUser,
id: string,
options: PinOptions
): Promise<FilecoinStoredPinStatus | undefined> {
const pinStatus = this.pins.get(id)
if (pinStatus == null) {
return undefined
}
// Update the pin object
if (options.name !== undefined) {
pinStatus.pin.name = options.name
}
if (options.origins !== undefined) {
pinStatus.pin.origins = options.origins
}
if (options.meta !== undefined) {
pinStatus.pin.meta = { ...pinStatus.pin.meta, ...options.meta }
}
this.pins.set(id, pinStatus)
return pinStatus
}
async cancel(_user: PinningServiceUser, id: string): Promise<void> {
// Get the pin to find the CAR file path
const pin = this.pins.get(id)
// Clean up active pin if it exists
const activePin = this.activePins.get(id)
if (activePin != null) {
try {
await activePin.blockstore.cleanup()
await activePin.helia.stop()
} catch (error) {
this.logger.warn({ pinId: id, error }, 'Error cleaning up cancelled pin')
}
this.activePins.delete(id)
}
// Delete the CAR file if it exists
if (pin?.filecoin?.carFilePath != null) {
try {
await unlink(pin.filecoin.carFilePath)
this.logger.info({ pinId: id, carFilePath: pin.filecoin.carFilePath }, 'Deleted CAR file for cancelled pin')
} catch (error: any) {
// Only warn if it's not a "file not found" error
if (error.code !== 'ENOENT') {
this.logger.warn({ pinId: id, carFilePath: pin.filecoin.carFilePath, error }, 'Failed to delete CAR file')
}
}
}
// Remove the pin from memory
this.pins.delete(id)
}
async list(
_user: PinningServiceUser,
query?: {
cid?: string
name?: string
status?: string
limit?: number
}
): Promise<{
count: number
results: FilecoinStoredPinStatus[]
}> {
let results = Array.from(this.pins.values())
// Apply filters
if (query?.cid != null && query.cid.length > 0) {
results = results.filter((pin) => pin.pin.cid === query.cid)
}
if (query?.name != null && query.name.length > 0) {
const nameFilter = query.name
results = results.filter((pin) => pin.pin.name?.includes(nameFilter) === true)
}
if (query?.status != null) {
results = results.filter((pin) => pin.status === query.status)
}
// Apply limit
if (query?.limit != null && query.limit > 0) {
results = results.slice(0, query.limit)
}
// Sort by created date (newest first)
results.sort((a, b) => b.created - a.created)
return {
count: results.length,
results,
}
}
/**
* Get statistics for all active pins
*/
getActivePinStats(): Array<{
pinId: string
cid: string
stats: CARBlockstoreStats
duration: number
}> {
return Array.from(this.activePins.entries()).map(([pinId, { metadata }]) => ({
pinId,
cid: metadata.carFilePath.split('/').pop()?.split('-')[0] ?? 'unknown',
stats: metadata.carStats,
duration: Date.now() - metadata.pinStarted,
}))
}
}