diff --git a/src/pin/index.ts b/src/pin/index.ts index 292bca874..86f899c38 100644 --- a/src/pin/index.ts +++ b/src/pin/index.ts @@ -4,6 +4,7 @@ import { createLs } from './ls.js' import { createRemote, type PinRemoteAPI } from './remote/index.js' import { createRmAll } from './rm-all.js' import { createRm } from './rm.js' +import { createUpdate } from './update.js' import type { AwaitIterable, HTTPRPCOptions } from '../index.js' import type { HTTPRPCClient } from '../lib/core.js' import type { CID } from 'multiformats/cid' @@ -93,6 +94,15 @@ export interface PinRmAllInput { recursive?: boolean } +export interface PinUpdateOptions extends HTTPRPCOptions { + /** + * Remove the old pin + * + * @default true + */ + unpin?: boolean +} + export interface PinAPI { /** * Adds an IPFS block to the pinset and also stores it to the IPFS @@ -183,6 +193,20 @@ export interface PinAPI { */ rmAll(source: AwaitIterable, options?: HTTPRPCOptions): AsyncIterable + /** + * Update a recursive pin + * + * @example + * ```js + * const oldCid = CID.parse('bafyreigdnpedjym3lvesqlllbry7zxjcp6fdvsusrh2mesqsdhd4idmzoq') + * const newCid = CID.parse('bafyreibt45jsjrdasabryzkzn7muhvigwyn2mmuw4hk26zr23fzyitmmy4') + * const result = await ipfs.pin.update(oldCid, newCid) + * console.log(result) + * // [CID('bafyreigdnpedjym3lvesqlllbry7zxjcp6fdvsusrh2mesqsdhd4idmzoq'), CID('bafyreibt45jsjrdasabryzkzn7muhvigwyn2mmuw4hk26zr23fzyitmmy4')] + * ``` + */ + update(from: string | CID, to: string | CID, options?: PinUpdateOptions): Promise + remote: PinRemoteAPI } @@ -193,6 +217,7 @@ export function createPin (client: HTTPRPCClient): PinAPI { ls: createLs(client), rmAll: createRmAll(client), rm: createRm(client), + update: createUpdate(client), remote: createRemote(client) } } diff --git a/src/pin/update.ts b/src/pin/update.ts new file mode 100644 index 000000000..a523618b2 --- /dev/null +++ b/src/pin/update.ts @@ -0,0 +1,20 @@ +import { CID } from 'multiformats/cid' +import { toUrlSearchParams } from '../lib/to-url-search-params.js' +import type { PinAPI } from './index.js' +import type { HTTPRPCClient } from '../lib/core.js' + +export function createUpdate (client: HTTPRPCClient): PinAPI['update'] { + return async function update (from, to, options = {}) { + const res = await client.post('pin/update', { + signal: options.signal, + searchParams: toUrlSearchParams({ + ...options, + arg: [from.toString(), to.toString()] + }), + headers: options.headers + }) + + const { Pins } = await res.json() + return Pins.map((cid: string) => CID.parse(cid)) + } +} diff --git a/test/interface-tests/src/pin/index.ts b/test/interface-tests/src/pin/index.ts index 6297efb8a..c416a413a 100644 --- a/test/interface-tests/src/pin/index.ts +++ b/test/interface-tests/src/pin/index.ts @@ -5,6 +5,7 @@ import { testLs } from './ls.js' import testRemote from './remote/index.js' import { testRmAll } from './rm-all.js' import { testRm } from './rm.js' +import { testUpdate } from './update.js' const tests = { add: testAdd, @@ -12,7 +13,8 @@ const tests = { ls: testLs, rm: testRm, rmAll: testRmAll, - remote: testRemote + remote: testRemote, + update: testUpdate } export default createSuite(tests) diff --git a/test/interface-tests/src/pin/update.ts b/test/interface-tests/src/pin/update.ts new file mode 100644 index 000000000..8862aefe8 --- /dev/null +++ b/test/interface-tests/src/pin/update.ts @@ -0,0 +1,197 @@ +/* eslint-env mocha */ + +import { expect } from 'aegir/chai' +import all from 'it-all' +import drain from 'it-drain' +import { fromString as uint8ArrayFromString } from 'uint8arrays/from-string' +import { getDescribe, getIt, type MochaConfig } from '../utils/mocha.js' +import { fixtures, clearPins, expectPinned, expectNotPinned, pinTypes } from './utils.js' +import type { KuboRPCClient } from '../../../../src/index.js' +import type { Factory, KuboNode } from 'ipfsd-ctl' + +export function testUpdate (factory: Factory, options: MochaConfig): void { + const describe = getDescribe(options) + const it = getIt(options) + + describe('.pin.update', function () { + this.timeout(50 * 1000) + + let ipfs: KuboRPCClient + + before(async function () { + ipfs = (await factory.spawn()).api + + await drain( + ipfs.addAll( + fixtures.files.map(file => ({ content: file.data })), { + pin: false + } + ) + ) + + await drain( + ipfs.addAll(fixtures.directory.files.map( + file => ({ + path: file.path, + content: file.data + }) + ), { + pin: false + }) + ) + }) + + after(async function () { + await factory.clean() + }) + + beforeEach(async function () { + return clearPins(ipfs) + }) + + it('should update a recursive pin', async () => { + // First pin the old object + await ipfs.pin.add(fixtures.directory.cid) + + // Create a new object to update to that links to one of the existing files + const newObject = await ipfs.dag.put({ + Data: uint8ArrayFromString('new object'), + Links: [{ + Name: 'ipfs-add.js', + Hash: fixtures.directory.files[0].cid, + Tsize: 0 + }] + }, { + storeCodec: 'dag-pb', + hashAlg: 'sha2-256' + }) + + // Update the pin + const result = await ipfs.pin.update(fixtures.directory.cid, newObject) + + // Check the result + expect(result).to.deep.include(newObject) + + // Verify old pin is removed + await expectNotPinned(ipfs, fixtures.directory.cid) + + // Verify new pin is added + await expectPinned(ipfs, newObject, pinTypes.recursive) + + // Verify the linked file is still pinned indirectly + await expectPinned(ipfs, fixtures.directory.files[0].cid, pinTypes.indirect) + + // Verify the old recursive object is not pinned + await expectNotPinned(ipfs, fixtures.directory.files[1].cid) + }) + + it('should update a recursive pin without removing the old pin', async () => { + // First pin the old object + await ipfs.pin.add(fixtures.directory.cid) + + // Create a new object to update to that links to one of the existing files + const newObject = await ipfs.dag.put({ + Data: uint8ArrayFromString('new object'), + Links: [{ + Name: 'ipfs-add.js', + Hash: fixtures.directory.files[0].cid, + Tsize: 0 + }] + }, { + storeCodec: 'dag-pb', + hashAlg: 'sha2-256' + }) + + // Update the pin without removing the old one + const result = await ipfs.pin.update(fixtures.directory.cid, newObject, { + unpin: false + }) + + // Check the result + expect(result).to.deep.include(newObject) + + // Verify old pin is still there + await expectPinned(ipfs, fixtures.directory.cid, pinTypes.recursive) + + // Verify new pin is added + await expectPinned(ipfs, newObject, pinTypes.recursive) + + // Verify the linked file is pinned indirectly through both objects + await expectPinned(ipfs, fixtures.directory.files[0].cid, pinTypes.indirect) + + // Verify the unlinked file is still pinned indirectly through the old object + await expectPinned(ipfs, fixtures.directory.files[1].cid, pinTypes.indirect) + }) + + it('should update a recursive pin with a name label', async () => { + // First pin the old object with a name + const pinName = 'my-label' + await ipfs.pin.add(fixtures.directory.cid, { name: pinName }) + + // Create a new object to update to that links to one of the existing files + const newObject = await ipfs.dag.put({ + Data: uint8ArrayFromString('new object'), + Links: [{ + Name: 'ipfs-add.js', + Hash: fixtures.directory.files[0].cid, + Tsize: 0 + }] + }, { + storeCodec: 'dag-pb', + hashAlg: 'sha2-256' + }) + + // Update the pin + const result = await ipfs.pin.update(fixtures.directory.cid, newObject) + + // Check the result + expect(result).to.deep.include(newObject) + + // Verify old pin is removed + await expectNotPinned(ipfs, fixtures.directory.cid) + // Verify the old recursive object is not pinned + await expectNotPinned(ipfs, fixtures.directory.files[1].cid) + + // Verify new pin is added and has the name label + const pinset = await all(ipfs.pin.ls({ name: pinName })) + expect(pinset).to.have.lengthOf(1) + expect(pinset[0].cid.toString()).to.equal(newObject.toString()) + expect(pinset[0].name).to.equal(pinName) + }) + + it('should fail to update a non-recursive pin', async () => { + // First pin the old object directly + await ipfs.pin.add(fixtures.directory.cid, { + recursive: false + }) + + // Create a new object to update to + const newObject = await ipfs.dag.put({ + Data: uint8ArrayFromString('new object'), + Links: [] + }, { + storeCodec: 'dag-pb', + hashAlg: 'sha2-256' + }) + + // Try to update the pin + await expect(ipfs.pin.update(fixtures.directory.cid, newObject)) + .to.eventually.be.rejectedWith(/not recursively pinned/) + }) + + it('should fail to update a non-existent pin', async () => { + // Create a new object to update to + const newObject = await ipfs.dag.put({ + Data: uint8ArrayFromString('new object'), + Links: [] + }, { + storeCodec: 'dag-pb', + hashAlg: 'sha2-256' + }) + + // Try to update a non-existent pin + await expect(ipfs.pin.update(fixtures.directory.cid, newObject)) + .to.eventually.be.rejectedWith(/not recursively pinned/) + }) + }) +} diff --git a/tsconfig.json b/tsconfig.json index 13a359963..5834051e2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,11 @@ { "extends": "aegir/src/config/tsconfig.aegir.json", "compilerOptions": { - "outDir": "dist" + "outDir": "dist", + "baseUrl": ".", + "paths": { + "kubo-rpc-client": ["./src/index.ts"] + } }, "include": [ "src",