Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/datasets/domain/models/Dataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ export interface DatasetVersionInfo {
minorNumber: number
state: DatasetVersionState
createTime: Date
lastUpdateTime: Date
/**
* The timestamp of the last update to this dataset version.
* Format: ISO 8601 string (e.g., "2023-06-01T12:34:56Z").
* Used for optimistic concurrency control to detect concurrent updates.
*/
lastUpdateTime: string
releaseTime?: Date
deaccessionNote?: string
}
Expand Down
2 changes: 1 addition & 1 deletion src/datasets/domain/repositories/IDatasetsRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export interface IDatasetsRepository {
datasetId: number | string,
dataset: DatasetDTO,
datasetMetadataBlocks: MetadataBlock[],
internalVersionNumber?: number
sourceLastUpdateTime?: string
): Promise<void>
deaccessionDataset(
datasetId: number | string,
Expand Down
6 changes: 3 additions & 3 deletions src/datasets/domain/useCases/UpdateDataset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export class UpdateDataset extends DatasetWriteUseCase<void> {
*
* @param {number | string} [datasetId] - The dataset identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers).
* @param {DatasetDTO} [updatedDataset] - DatasetDTO object including the updated dataset metadata field values for each metadata block.
* @param {number} [internalVersionNumber] - The internal version number of the dataset. If another user updates the dataset version metadata before you send the update request, data inconsistencies may occur. To prevent this, you can use the optional internalVersionNumber parameter. This parameter must include the internal version number corresponding to the dataset version being updated. Note that internal version numbers increase sequentially with each version update.
* @param {string} [sourceLastUpdateTime] - The lastUpdateTime value from the dataset. If another user updates the dataset version metadata before you send the update request, data inconsistencies may occur. To prevent this, you can use the optional sourceLastUpdateTime parameter. This parameter must include the lastUpdateTime value corresponding to the dataset version being updated.
* @returns {Promise<void>} - This method does not return anything upon successful completion.
* @throws {ResourceValidationError} - If there are validation errors related to the provided information.
* @throws {ReadError} - If there are errors while reading data.
Expand All @@ -27,15 +27,15 @@ export class UpdateDataset extends DatasetWriteUseCase<void> {
async execute(
datasetId: number | string,
updatedDataset: DatasetDTO,
internalVersionNumber?: number
sourceLastUpdateTime?: string
): Promise<void> {
const metadataBlocks = await this.getNewDatasetMetadataBlocks(updatedDataset)
this.getNewDatasetValidator().validate(updatedDataset, metadataBlocks)
return this.getDatasetsRepository().updateDataset(
datasetId,
updatedDataset,
metadataBlocks,
internalVersionNumber
sourceLastUpdateTime
)
}
}
6 changes: 2 additions & 4 deletions src/datasets/infra/repositories/DatasetsRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,16 +252,14 @@ export class DatasetsRepository extends ApiRepository implements IDatasetsReposi
datasetId: string | number,
dataset: DatasetDTO,
datasetMetadataBlocks: MetadataBlock[],
internalVersionNumber?: number
sourceLastUpdateTime?: string
): Promise<void> {
return this.doPut(
this.buildApiEndpoint(this.datasetsResourceName, `editMetadata`, datasetId),
transformDatasetModelToUpdateDatasetRequestPayload(dataset, datasetMetadataBlocks),
{
replace: true,
...(typeof internalVersionNumber === 'number' && {
sourceInternalVersionNumber: internalVersionNumber
})
...(sourceLastUpdateTime && { sourceLastUpdateTime })
}
)
.then(() => undefined)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export const transformDatasetPreviewPayloadToDatasetPreview = (
minorNumber: datasetPreviewPayload.minorVersion,
state: datasetPreviewPayload.versionState as DatasetVersionState,
createTime: new Date(datasetPreviewPayload.createdAt),
lastUpdateTime: new Date(datasetPreviewPayload.updatedAt),
lastUpdateTime: datasetPreviewPayload.updatedAt,
...(datasetPreviewPayload.published_at && {
releaseTime: new Date(datasetPreviewPayload.published_at)
})
Expand Down Expand Up @@ -72,7 +72,7 @@ export const transformMyDataDatasetPreviewPayloadToDatasetPreview = (
minorNumber: datasetPreviewPayload.minorVersion,
state: datasetPreviewPayload.versionState as DatasetVersionState,
createTime: new Date(datasetPreviewPayload.createdAt),
lastUpdateTime: new Date(datasetPreviewPayload.updatedAt),
lastUpdateTime: datasetPreviewPayload.updatedAt,
...(datasetPreviewPayload.published_at && {
releaseTime: new Date(datasetPreviewPayload.published_at)
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ export const transformVersionPayloadToDataset = (
minorNumber: versionPayload.versionMinorNumber,
state: versionPayload.versionState as DatasetVersionState,
createTime: new Date(versionPayload.createTime),
lastUpdateTime: new Date(versionPayload.lastUpdateTime),
lastUpdateTime: versionPayload.lastUpdateTime,
releaseTime: new Date(versionPayload.releaseTime),
deaccessionNote: versionPayload.deaccessionNote
},
Expand Down
6 changes: 6 additions & 0 deletions src/files/domain/models/FileModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ export interface FileModel {
tabularTags?: string[]
creationDate?: string
publicationDate?: string
/**
* The timestamp of the last update to this file record.
* Format: ISO 8601 string (e.g., "2023-06-01T12:34:56Z").
* Used for optimistic concurrency control to detect concurrent updates.
*/
lastUpdateTime: string
deleted: boolean
tabularData: boolean
fileAccessRequest?: boolean
Expand Down
3 changes: 2 additions & 1 deletion src/files/domain/repositories/IFilesRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ export interface IFilesRepository {

updateFileMetadata(
fileId: number | string,
updateFileMetadataDTO: UpdateFileMetadataDTO
updateFileMetadataDTO: UpdateFileMetadataDTO,
sourceLastUpdateTime?: string
): Promise<void>

updateFileTabularTags(
Expand Down
10 changes: 8 additions & 2 deletions src/files/domain/useCases/UpdateFileMetadata.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,18 @@ export class UpdateFileMetadata implements UseCase<void> {
*
* @param {number | string} [fileId] - The file identifier, which can be a string (for persistent identifiers), or a number (for numeric identifiers).
* @param {UpdateFileMetadataDTO} [updateFileMetadataDTO] - The DTO containing the metadata updates.
* @param {string} [sourceLastUpdateTime] - The lastUpdateTime value from the file. If another user updates the file metadata before you send the update request, data inconsistencies may occur. To prevent this, you can use the optional sourceLastUpdateTime parameter. This parameter must include the lastUpdateTime value corresponding to the file being updated.
* @returns {Promise<void>}
*/
async execute(
fileId: number | string,
updateFileMetadataDTO: UpdateFileMetadataDTO
updateFileMetadataDTO: UpdateFileMetadataDTO,
sourceLastUpdateTime?: string
): Promise<void> {
await this.filesRepository.updateFileMetadata(fileId, updateFileMetadataDTO)
await this.filesRepository.updateFileMetadata(
fileId,
updateFileMetadataDTO,
sourceLastUpdateTime
)
}
}
7 changes: 5 additions & 2 deletions src/files/infra/repositories/FilesRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -369,15 +369,18 @@ export class FilesRepository extends ApiRepository implements IFilesRepository {

public async updateFileMetadata(
fileId: string | number,
updateFileMetadata: UpdateFileMetadataDTO
updateFileMetadata: UpdateFileMetadataDTO,
sourceLastUpdateTime?: string
): Promise<void> {
const formData = new FormData()
formData.append('jsonData', JSON.stringify(updateFileMetadata))

return this.doPost(
this.buildApiEndpoint(this.filesResourceName, `${fileId}/metadata`),
formData,
{},
{
...(sourceLastUpdateTime && { sourceLastUpdateTime })
},
ApiConstants.CONTENT_TYPE_MULTIPART_FORM_DATA
)
.then(() => undefined)
Expand Down
1 change: 1 addition & 0 deletions src/files/infra/repositories/transformers/FilePayload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export interface FilePayload {
tabularTags?: string[]
creationDate?: string
publicationDate?: string
lastUpdateTime: string
deleted: boolean
tabularData: boolean
fileAccessRequest?: boolean
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ const transformFilePayloadToFile = (filePayload: FilePayload): FileModel => {
}),
...(filePayload.dataFile.isPartOf && {
isPartOf: transformPayloadToOwnerNode(filePayload.dataFile.isPartOf)
})
}),
lastUpdateTime: filePayload.dataFile.lastUpdateTime
}
}

Expand Down
33 changes: 12 additions & 21 deletions test/integration/datasets/DatasetsRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,6 @@ describe('DatasetsRepository', () => {
false
)
expect(actual.id).toBe(testDatasetIds.numericId)
expect(actual.internalVersionNumber).toBe(1)
})

test('should return dataset when it is deaccessioned and includeDeaccessioned param is set', async () => {
Expand Down Expand Up @@ -1132,8 +1131,8 @@ describe('DatasetsRepository', () => {
}
])
})
// TODO: add this test when https://github.com/IQSS/dataverse-client-javascript/issues/343 is fixed
test.skip('should throw error if trying to update an outdated internal version dataset', async () => {

test('should throw error if sending an outdated lastUpdateTime', async () => {
const testDataset = {
metadataBlockValues: [
{
Expand Down Expand Up @@ -1184,43 +1183,35 @@ describe('DatasetsRepository', () => {
false,
false
)
const actualCreatedDatasetInternalVersionNumber = actualCreatedDataset.internalVersionNumber

expect(actualCreatedDataset.internalVersionNumber).toBe(1)
const firstLastUpdateTime = actualCreatedDataset.versionInfo.lastUpdateTime

// Now update the dataset and then update again with the same internal version number
// Now update the dataset and then update again with the same source last update time
const updatedDsDescription = 'This is the updated description of the dataset.'
testDataset.metadataBlockValues[0].fields.dsDescription[0].dsDescriptionValue =
updatedDsDescription

// First update sending the correct internal version number
// Wait for 2 seconds
await new Promise((resolve) => setTimeout(resolve, 2000))

// First update sending the correct lastUpdateTime
await sut.updateDataset(
createdDataset.numericId,
testDataset,
[citationMetadataBlock],
actualCreatedDatasetInternalVersionNumber
)

const afterFirstUpdateDataset = await sut.getDataset(
createdDataset.numericId,
DatasetNotNumberedVersion.LATEST,
false,
false
firstLastUpdateTime
)

expect(afterFirstUpdateDataset.internalVersionNumber).toBe(2)

//Now try to update again with the previous internal version number
//Now try to update again with the previous lastUpdateTime
const expectedError = new WriteError(
`[400] Dataset internal version number ${actualCreatedDatasetInternalVersionNumber} is outdated`
`[400] Internal version timestamp ${firstLastUpdateTime} is outdated`
)

await expect(
sut.updateDataset(
createdDataset.numericId,
testDataset,
[citationMetadataBlock],
actualCreatedDatasetInternalVersionNumber
firstLastUpdateTime
)
).rejects.toThrow(expectedError)
})
Expand Down
55 changes: 55 additions & 0 deletions test/integration/files/FilesRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,61 @@ describe('FilesRepository', () => {
errorExpected
)
})

test('should throw error when using outdated sourceLastUpdateTime', async () => {
const newDatasetIds = await createDataset.execute(TestConstants.TEST_NEW_DATASET_DTO)
await uploadFileViaApi(newDatasetIds.numericId, testTextFile1Name)
const filesSubset = await sut.getDatasetFiles(
newDatasetIds.numericId,
latestDatasetVersionId,
false,
FileOrderCriteria.NAME_AZ
)
const fileId = filesSubset.files[0].id

await registerFileViaApi(fileId)

// Fetch file to obtain initial lastUpdateTime from returned model including dataset version
const fileInfo: FileModel = (await sut.getFile(
fileId,
DatasetNotNumberedVersion.LATEST,
false,
false
)) as FileModel

const lastUpdateTimeOne = fileInfo.lastUpdateTime

// Wait for 2 seconds
await new Promise((resolve) => setTimeout(resolve, 2_000))

// First update using correct lastUpdateTime should succeed
await sut.updateFileMetadata(fileId, { description: 'First update desc.' }, lastUpdateTimeOne)

// Refetch to get new lastUpdateTime
const fileInfoAfterFirstUpdate: FileModel = (await sut.getFile(
fileId,
DatasetNotNumberedVersion.LATEST,
false,
false
)) as FileModel

const lastUpdateTimeTwo = fileInfoAfterFirstUpdate.lastUpdateTime

expect(lastUpdateTimeTwo).not.toBe(lastUpdateTimeOne)

// Wait for 2 seconds
await new Promise((resolve) => setTimeout(resolve, 2_000))

// Second update using stale lastUpdateTimeOne should fail
const expectedError = new WriteError(
`[400] Internal version timestamp ${lastUpdateTimeOne} is outdated`
)
await expect(
sut.updateFileMetadata(fileId, { description: 'Second update attempt.' }, lastUpdateTimeOne)
).rejects.toThrow(expectedError)

await deletePublishedDatasetViaApi(newDatasetIds.persistentId)
})
})

describe('updateFileTabularTags', () => {
Expand Down
2 changes: 1 addition & 1 deletion test/testHelpers/datasets/datasetHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const createDatasetModel = (
minorNumber: 0,
state: DatasetVersionState.RELEASED,
createTime: new Date(DATASET_CREATE_TIME_STR),
lastUpdateTime: new Date(DATASET_UPDATE_TIME_STR),
lastUpdateTime: DATASET_UPDATE_TIME_STR,
releaseTime: new Date(DATASET_RELEASE_TIME_STR),
deaccessionNote: undefined
},
Expand Down
2 changes: 1 addition & 1 deletion test/testHelpers/datasets/datasetPreviewHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ export const createDatasetPreviewModel = (): DatasetPreview => {
minorNumber: 0,
state: DatasetVersionState.RELEASED,
createTime: new Date(DATASET_CREATE_TIME_STR),
lastUpdateTime: new Date(DATASET_UPDATE_TIME_STR),
lastUpdateTime: DATASET_UPDATE_TIME_STR,
releaseTime: new Date(DATASET_RELEASE_TIME_STR)
},
citation: DATASET_CITATION_HTML,
Expand Down
6 changes: 4 additions & 2 deletions test/testHelpers/files/filesHelper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ export const createFileModel = (): FileModel => {
originalSize: 127426,
originalName: 'originalName',
tabularTags: ['tag1', 'tag2'],
publicationDate: '2023-07-11'
publicationDate: '2023-07-11',
lastUpdateTime: '2023-07-11'
}
}

Expand Down Expand Up @@ -122,7 +123,8 @@ export const createFilePayload = (): FilePayload => {
originalSize: 127426,
originalName: 'originalName',
tabularTags: ['tag1', 'tag2'],
publicationDate: '2023-07-11'
publicationDate: '2023-07-11',
lastUpdateTime: '2023-07-11'
}
}
}
Expand Down
15 changes: 12 additions & 3 deletions test/unit/files/UpdateFileMetadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ describe('UpdateFileMetadata', () => {

await sut.execute(1, testFileMetadata)

expect(filesRepositoryStub.updateFileMetadata).toHaveBeenCalledWith(1, testFileMetadata)
expect(filesRepositoryStub.updateFileMetadata).toHaveBeenCalledWith(
1,
testFileMetadata,
undefined
)
expect(filesRepositoryStub.updateFileMetadata).toHaveBeenCalledTimes(1)
})

Expand All @@ -28,7 +32,8 @@ describe('UpdateFileMetadata', () => {

expect(filesRepositoryStub.updateFileMetadata).toHaveBeenCalledWith(
'doi:10.5072/FK2/HC6KTB',
testFileMetadata
testFileMetadata,
undefined
)
expect(filesRepositoryStub.updateFileMetadata).toHaveBeenCalledTimes(1)
})
Expand All @@ -41,6 +46,10 @@ describe('UpdateFileMetadata', () => {
const sut = new UpdateFileMetadata(filesRepositoryStub)

await expect(sut.execute(1, testFileMetadata)).rejects.toThrow(WriteError)
expect(filesRepositoryStub.updateFileMetadata).toHaveBeenCalledWith(1, testFileMetadata)
expect(filesRepositoryStub.updateFileMetadata).toHaveBeenCalledWith(
1,
testFileMetadata,
undefined
)
})
})