diff --git a/packages/w3up-client/README.md b/packages/w3up-client/README.md index 154fe4a1e..51b218b4e 100644 --- a/packages/w3up-client/README.md +++ b/packages/w3up-client/README.md @@ -394,6 +394,7 @@ sequenceDiagram - [`addProof`](#addproof) - [`delegations`](#delegations) - [`createDelegation`](#createdelegation) + - [`remove`](#remove) - [`capability.access.authorize`](#capabilityaccessauthorize) - [`capability.access.claim`](#capabilityaccessclaim) - [`capability.space.info`](#capabilityspaceinfo) @@ -594,6 +595,21 @@ function createDelegation ( Create a delegation to the passed audience for the given abilities with the _current_ space as the resource. +### `remove` + +```ts +function remove ( + contentCID?: CID + options: { + shards?: boolean + } = {} +): Promise +``` + +Removes association of a content CID with the space. Optionally, also removes association of CAR shards with space. + +⚠️ If `shards` option is `true` all shards will be deleted even if there is another upload(s) that reference same shards, which in turn could corrupt those uploads. + ### `getReceipt` ```ts diff --git a/packages/w3up-client/src/client.js b/packages/w3up-client/src/client.js index c77d6d61d..ca7a5deb9 100644 --- a/packages/w3up-client/src/client.js +++ b/packages/w3up-client/src/client.js @@ -311,4 +311,50 @@ export class Client extends Base { proofs: options.proofs, }) } + + /** + * Removes association of a content CID with the space. Optionally, also removes + * association of CAR shards with space. + * + * ⚠️ If `shards` option is `true` all shards will be deleted even if there is another upload(s) that + * reference same shards, which in turn could corrupt those uploads. + * + * @param {import('multiformats').UnknownLink} contentCID + * @param {object} [options] + * @param {boolean} [options.shards] + */ + async remove(contentCID, options = {}) { + // Shortcut if there is no request to remove shards + if (!options.shards) { + // Remove association of content CID with selected space. + await this.capability.upload.remove(contentCID) + return + } + + // Get shards associated with upload. + const upload = await this.capability.upload.get(contentCID) + + // Remove shards + if (upload.shards?.length) { + await Promise.allSettled( + upload.shards.map(async (shard) => { + try { + await this.capability.store.remove(shard) + } catch (/** @type {any} */ error) { + /* c8 ignore start */ + // If not found, we can tolerate error as it may be a consecutive call for deletion where first failed + if (error?.cause?.name !== 'StoreItemNotFound') { + throw new Error(`failed to remove shard: ${shard}`, { + cause: error, + }) + } + /* c8 ignore stop */ + } + }) + ) + } + + // Remove association of content CID with selected space. + await this.capability.upload.remove(contentCID) + } } diff --git a/packages/w3up-client/test/client.test.js b/packages/w3up-client/test/client.test.js index 3d92d3748..9ac29abd5 100644 --- a/packages/w3up-client/test/client.test.js +++ b/packages/w3up-client/test/client.test.js @@ -4,6 +4,7 @@ import { create as createServer, parseLink, provide, + error, } from '@ucanto/server' import * as CAR from '@ucanto/transport/car' import * as Signer from '@ucanto/principal/ed25519' @@ -11,6 +12,7 @@ import * as StoreCapabilities from '@web3-storage/capabilities/store' import * as UploadCapabilities from '@web3-storage/capabilities/upload' import * as UCANCapabilities from '@web3-storage/capabilities/ucan' import { AgentData } from '@web3-storage/access/agent' +import { StoreItemNotFound } from '../../upload-api/src/store/lib.js' import { randomBytes, randomCAR } from './helpers/random.js' import { toCAR } from './helpers/car.js' import { mockService, mockServiceConf } from './helpers/mocks.js' @@ -480,4 +482,247 @@ describe('Client', () => { assert.equal(typeof client.capability.upload.remove, 'function') }) }) + + describe('remove', () => { + it('should remove an uploaded file from the service with its shards', async () => { + const bytes = await randomBytes(128) + const uploadedCar = await toCAR(bytes) + const contentCID = uploadedCar.roots[0] + + const service = mockService({ + store: { + remove: provide(StoreCapabilities.remove, ({ invocation }) => { + return { ok: { size: uploadedCar.size } } + }), + }, + upload: { + get: provide(UploadCapabilities.get, ({ invocation }) => { + return { + ok: { + root: uploadedCar.roots[0], + shards: [uploadedCar.cid], + insertedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + } + }), + remove: provide(UploadCapabilities.remove, ({ invocation }) => { + return { + ok: { + root: uploadedCar.roots[0], + shards: [uploadedCar.cid], + }, + } + }), + }, + }) + + const server = createServer({ + id: await Signer.generate(), + service, + codec: CAR.inbound, + validateAuthorization, + }) + + const alice = new Client(await AgentData.create(), { + // @ts-ignore + serviceConf: await mockServiceConf(server), + }) + + // setup space + const space = await alice.createSpace('upload-test') + const auth = await space.createAuthorization(alice) + await alice.addSpace(auth) + await alice.setCurrentSpace(space.did()) + + await assert.doesNotReject(() => + alice.remove(contentCID, { shards: true }) + ) + + assert(service.upload.get.called) + assert.equal(service.upload.get.callCount, 1) + assert(service.upload.remove.called) + assert.equal(service.upload.remove.callCount, 1) + assert(service.store.remove.called) + assert.equal(service.store.remove.callCount, 1) + }) + + it('should remove an uploaded file from the service without its shards by default', async () => { + const bytes = await randomBytes(128) + const uploadedCar = await toCAR(bytes) + const contentCID = uploadedCar.roots[0] + + const service = mockService({ + upload: { + get: provide(UploadCapabilities.get, ({ invocation }) => { + return { + ok: { + root: uploadedCar.roots[0], + shards: [uploadedCar.cid], + insertedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + } + }), + remove: provide(UploadCapabilities.remove, ({ invocation }) => { + return { + ok: { + root: uploadedCar.roots[0], + shards: [uploadedCar.cid], + }, + } + }), + }, + store: { + remove: provide(StoreCapabilities.remove, ({ invocation }) => { + return { ok: { size: uploadedCar.size } } + }), + }, + }) + + const server = createServer({ + id: await Signer.generate(), + service, + codec: CAR.inbound, + validateAuthorization, + }) + + const alice = new Client(await AgentData.create(), { + // @ts-ignore + serviceConf: await mockServiceConf(server), + }) + + // setup space + const space = await alice.createSpace('upload-test') + const auth = await space.createAuthorization(alice) + await alice.addSpace(auth) + await alice.setCurrentSpace(space.did()) + + await assert.doesNotReject(() => alice.remove(contentCID)) + + assert(service.upload.remove.called) + assert.equal(service.upload.remove.callCount, 1) + assert.equal(service.store.remove.callCount, 0) + }) + + it('should fail to remove uploaded shards if upload is not found', async () => { + const bytes = await randomBytes(128) + const uploadedCar = await toCAR(bytes) + const contentCID = uploadedCar.roots[0] + + const service = mockService({ + upload: { + get: provide(UploadCapabilities.get, ({ invocation }) => { + return error(new StoreItemNotFound('did:web:any', uploadedCar.cid)) + }), + }, + }) + + const server = createServer({ + id: await Signer.generate(), + service, + codec: CAR.inbound, + validateAuthorization, + }) + + const alice = new Client(await AgentData.create(), { + // @ts-ignore + serviceConf: await mockServiceConf(server), + }) + + // setup space + const space = await alice.createSpace('upload-test') + const auth = await space.createAuthorization(alice) + await alice.addSpace(auth) + await alice.setCurrentSpace(space.did()) + + await assert.rejects(alice.remove(contentCID, { shards: true })) + + assert(service.upload.get.called) + assert.equal(service.upload.get.callCount, 1) + assert.equal(service.store.remove.callCount, 0) + assert.equal(service.upload.remove.callCount, 0) + }) + + it('should not fail to remove if shard is not found', async () => { + const bytesArray = [await randomBytes(128), await randomBytes(128)] + const uploadedCars = await Promise.all( + bytesArray.map((bytes) => toCAR(bytes)) + ) + const contentCID = uploadedCars[0].roots[0] + + const service = mockService({ + upload: { + get: provide(UploadCapabilities.get, ({ invocation }) => { + return { + ok: { + root: uploadedCars[0].roots[0], + shards: uploadedCars.map((car) => car.cid), + insertedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + } + }), + remove: provide(UploadCapabilities.remove, ({ invocation }) => { + return { + ok: { + root: uploadedCars[0].roots[0], + shards: uploadedCars.map((car) => car.cid), + }, + } + }), + }, + store: { + remove: provide( + StoreCapabilities.remove, + ({ invocation, capability }) => { + // Fail for first as not found) + if (capability.nb.link.equals(uploadedCars[0].cid)) { + return error( + new StoreItemNotFound('did:web:any', uploadedCars[0].cid) + ) + } + return { ok: { size: uploadedCars[1].size } } + } + ), + }, + }) + + const server = createServer({ + id: await Signer.generate(), + service, + codec: CAR.inbound, + validateAuthorization, + }) + + const alice = new Client(await AgentData.create(), { + // @ts-ignore + serviceConf: await mockServiceConf(server), + }) + + // setup space + const space = await alice.createSpace('upload-test') + const auth = await space.createAuthorization(alice) + await alice.addSpace(auth) + await alice.setCurrentSpace(space.did()) + + await assert.doesNotReject(() => + alice.remove(contentCID, { shards: true }) + ) + + assert(service.upload.remove.called) + assert.equal(service.upload.remove.callCount, 1) + assert.equal(service.store.remove.callCount, 2) + }) + + it('should not allow remove without a current space', async () => { + const alice = new Client(await AgentData.create()) + + const bytes = await randomBytes(128) + const uploadedCar = await toCAR(bytes) + const contentCID = uploadedCar.roots[0] + + await assert.rejects(alice.remove(contentCID, { shards: true })) + }) + }) })