Skip to content

Commit

Permalink
feat: add remove high level function to client (#1248)
Browse files Browse the repository at this point in the history
  • Loading branch information
vasco-santos committed Jan 24, 2024
1 parent e34eed1 commit 104b8de
Show file tree
Hide file tree
Showing 3 changed files with 307 additions and 0 deletions.
16 changes: 16 additions & 0 deletions packages/w3up-client/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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<void>
```

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
Expand Down
46 changes: 46 additions & 0 deletions packages/w3up-client/src/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
245 changes: 245 additions & 0 deletions packages/w3up-client/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@ import {
create as createServer,
parseLink,
provide,
error,
} from '@ucanto/server'
import * as CAR from '@ucanto/transport/car'
import * as Signer from '@ucanto/principal/ed25519'
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'
Expand Down Expand Up @@ -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 }))
})
})
})

0 comments on commit 104b8de

Please sign in to comment.