Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
65acf6a
feat: add configurable file upload options and related tests
ErykKul Dec 5, 2025
5c78d15
Merge branch 'develop' into feature/configurable-uploads
ErykKul May 4, 2026
3c2aaf8
Remove FilesConfig/useS3Tagging; drive tagging from server response
ErykKul May 4, 2026
4fbcffe
Polish configurable upload PR
ErykKul May 4, 2026
c60e2b7
Trim upload PR scope
ErykKul May 4, 2026
8c1e158
typo fix
ErykKul May 4, 2026
0dccf99
Isolate set default template functional test
ErykKul May 4, 2026
f29ad91
Revert "Isolate set default template functional test"
ErykKul May 4, 2026
07bf082
Scope format and lint scripts to ./src
ErykKul May 5, 2026
571d625
Re-export DataverseApiAuthMechanism from public core surface
ErykKul May 5, 2026
397d662
Add tree node listing SDK helpers (#6691)
ErykKul May 5, 2026
0df68ec
test: follow IQSS/dataverse#12182 storage-driver endpoint move
ErykKul May 5, 2026
17822cb
Document tree-node use cases and DataverseApiAuthMechanism re-export
ErykKul May 5, 2026
bb19183
Re-export DirectUploadClientConfig from public files index
ErykKul May 7, 2026
cccbc5a
Restore lint/format coverage for tests
ErykKul May 7, 2026
8773e82
Document DirectUploadClient constructor break and tagging change in C…
ErykKul May 8, 2026
460f36d
Document the tree endpoint Cache-Control as private, immutable to mat…
ErykKul May 8, 2026
21550f8
Document older-server backwards-compat behavior for the tagging field
ErykKul May 8, 2026
3d6f638
Default x-amz-tagging to dv-state=temp when the server omits the field
ErykKul May 9, 2026
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
13 changes: 12 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,16 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel

### Added

- Datasets: `listDatasetTreeNode` use case and repository method backing `GET /datasets/{id}/versions/{versionId}/tree` for paginated, lazy listing of folders/files inside a dataset version. Returns `FileTreePage` with folder-first ordering, opaque keyset cursors, and per-file `downloadUrl`.
- Datasets: `iterateDatasetTreeNode` async generator that walks the cursor chain so callers can consume one folder's children without driving pagination by hand.
- Core: re-export `DataverseApiAuthMechanism` from the public surface so consumers of the standalone reusable bundles (e.g. `dv-tree-view`, `dv-uploader`) can import it without reaching into `core/...`.
- Files: export `DirectUploadClientConfig` from the public files surface so consumers can construct a `DirectUploadClient` with the new configuration object.

### Changed

- **BREAKING**: `DirectUploadClient` constructor signature changed from `(filesRepository, maxMultipartRetries = 5)` to `(filesRepository, config: DirectUploadClientConfig = {})`. `config` now holds `maxMultipartRetries` (default 5) and the new `fileUploadTimeoutMs` (default 60000). Existing TypeScript consumers passing the second argument as a number will need to migrate to `{ maxMultipartRetries: N }`.
- Files: `DirectUploadClient` now reads the `tagging` field from the upload-destination response so operators on storage that doesn't accept S3 tags can opt out per-driver via `dataverse.files.<id>.disable-tagging=true`. The default behaviour is unchanged: when the server omits the field the client still sends `x-amz-tagging: dv-state=temp` (the same tag that earlier SDK versions hard-coded), so the new SDK is backwards-compatible with Dataverse releases that predate the response field. A server that explicitly returns an empty `tagging` value tells the client to skip the header entirely.

### Fixed

### Removed
Expand All @@ -28,6 +36,8 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel
- Templates: Added `setTemplateAsDefault` use case and repository method to support Dataverse endpoint `POST /dataverses/{id}/template/default/{templateId}`.
- Templates: Added `unsetTemplateAsDefault` use case and repository method to support Dataverse endpoint `DELETE /dataverses/{id}/template/default`.
- New Use Case: [Update Terms of Access](./docs/useCases.md#update-terms-of-access).
- Files: Direct uploads now forward the `tagging` value returned by the upload destination response as the `x-amz-tagging` header for single-part uploads.
- Files: Added a `DirectUploadClientConfig` object for configuring multipart upload retries and upload timeout.
- Guestbooks: Added use cases and repository support for guestbook creation, listing, and enabling/disabling.
- Guestbooks: Added dataset-level guestbook assignment and removal support via `assignDatasetGuestbook` (`PUT /api/datasets/{identifier}/guestbook`) and `removeDatasetGuestbook` (`DELETE /api/datasets/{identifier}/guestbook`).
- Datasets/Guestbooks: Added `guestbookId` in `getDataset` responses.
Expand All @@ -38,10 +48,11 @@ This changelog follows the principles of [Keep a Changelog](https://keepachangel

### Changed

- Add pagination query parameters to Dataset Version Summeries and File Version Summaries use cases.
- Add pagination query parameters to Dataset Version Summaries and File Version Summaries use cases.
- Templates: Rename `CreateDatasetTemplateDTO` to `CreateTemplateDTO`.
- Templates: Rename `createDatasetTemplate` repository method to `createTemplate`.
- Templates: Rename `getDatasetTemplates` repository method to `getTemplatesByCollectionId`.
- Files: `DirectUploadClient` constructor now accepts a `DirectUploadClientConfig` object instead of a plain number for `maxMultipartRetries`.

### Fixed

Expand Down
75 changes: 75 additions & 0 deletions docs/useCases.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ The different use cases currently available in the package are classified below,
- [Get Dataset Available Dataset Types](#get-dataset-available-dataset-types)
- [Get Dataset Available Dataset Type](#get-dataset-available-dataset-type)
- [Get Dataset Upload Limits](#get-dataset-upload-limits)
- [List a Folder of a Dataset Version (Tree View)](#list-a-folder-of-a-dataset-version-tree-view)
- [Iterate a Folder of a Dataset Version (Tree View)](#iterate-a-folder-of-a-dataset-version-tree-view)
- [Datasets write use cases](#datasets-write-use-cases)
- [Create a Dataset](#create-a-dataset)
- [Update a Dataset](#update-a-dataset)
Expand Down Expand Up @@ -1619,6 +1621,79 @@ _See [use case](../src/datasets/domain/useCases/GetDatasetUploadLimits.ts) imple

If the backend does not define any quota limits for the dataset, the returned object can be empty (`{}`).

#### List a Folder of a Dataset Version (Tree View)

Returns a [FileTreePage](../src/datasets/domain/models/FileTreePage.ts) for the immediate children (folders and files) inside a folder of a dataset version, intended for lazy tree-view UIs that fetch each folder's children on demand.

Folders come first, then files. Both are name-sorted (case-insensitive); files break ties on data file id for stability. The page carries an opaque `nextCursor` token; clients echo it back to fetch the next page and never construct one themselves.

##### Example call:

```typescript
import { listDatasetTreeNode, FileTreePage } from '@iqss/dataverse-client-javascript'

/* ... */

const datasetId = 'doi:10.77777/FK2/AAAAAA'

listDatasetTreeNode
.execute({
datasetId,
datasetVersionId: '1.0',
path: 'data/raw',
limit: 100
})
.then((page: FileTreePage) => {
/* ... */
})

/* ... */
```

_See [use case](../src/datasets/domain/useCases/ListDatasetTreeNode.ts) implementation_.

`datasetId` can be a numeric id or a persistent identifier string. `datasetVersionId` is optional and defaults to `DatasetNotNumberedVersion.LATEST`.

Other optional parameters: `cursor` (opaque, from a previous response), `include` (`'all' | 'folders' | 'files'`, default `'all'`), `order` (`'NameAZ' | 'NameZA'`, default `'NameAZ'`), `includeDeaccessioned` (default `false`), and `originals` (when `true`, the per-file `downloadUrl` carries `?format=original`).

For published, non-deaccessioned versions the underlying API emits `ETag` + `Cache-Control: private, immutable` headers. The `private` directive keeps responses out of shared proxy caches because the route is auth-required; the browser's own cache still benefits from `immutable`. Drafts and deaccessioned versions don't.

#### Iterate a Folder of a Dataset Version (Tree View)

Returns an async generator over [FileTreeNode](../src/datasets/domain/models/FileTreeNode.ts) values for one folder, walking the cursor chain so callers can consume the children without driving pagination by hand.

##### Example call:

```typescript
import {
iterateDatasetTreeNode,
FileTreeNode,
isFileTreeFileNode
} from '@iqss/dataverse-client-javascript'

/* ... */

const datasetId = 'doi:10.77777/FK2/AAAAAA'

for await (const node of iterateDatasetTreeNode.execute({
datasetId,
datasetVersionId: '1.0',
path: 'data/raw'
})) {
if (isFileTreeFileNode(node)) {
/* ... */
} else {
/* node is a folder ... */
}
}

/* ... */
```

_See [use case](../src/datasets/domain/useCases/IterateDatasetTreeNode.ts) implementation_.

The generator stops after yielding everything in the requested folder; it does **not** descend into subfolders. Pass each subfolder's `path` back through `iterateDatasetTreeNode` if you want a recursive walk.

## Files

### Files read use cases
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,10 @@
"test:coverage": "jest --coverage -c jest.config.ts",
"test:coverage:check": "jest --coverage --ci --config jest.config.ts",
"lint": "npm run lint:eslint && npm run lint:prettier",
"lint:fix": "eslint --fix --ext .ts ./src --ignore-path .gitignore .",
"lint:eslint": "eslint --ignore-path .gitignore .",
"lint:fix": "eslint --fix --ext .ts --ignore-path .gitignore ./src ./test/unit ./test/integration ./test/functional",
"lint:eslint": "eslint --ignore-path .gitignore ./src ./test/unit ./test/integration ./test/functional",
"lint:prettier": "prettier --check '**/*.(yml|json|md)'",
"format": "prettier --write './**/*.{js,ts,md,json,yml,md}' --config ./.prettierrc",
"format": "prettier --write './src/**/*.{js,ts,md,json,yml,md}' './test/{unit,integration,functional}/**/*.{js,ts,json}' --config ./.prettierrc",
"typecheck": "tsc --noEmit",
"prepare": "husky"
},
Expand Down
2 changes: 1 addition & 1 deletion src/core/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export { ReadError } from './domain/repositories/ReadError'
export { WriteError } from './domain/repositories/WriteError'
export { ApiConfig } from './infra/repositories/ApiConfig'
export { ApiConfig, DataverseApiAuthMechanism } from './infra/repositories/ApiConfig'
export { DvObjectOwnerNode, DvObjectType } from './domain/models/DvObjectOwnerNode'
export { PublicationStatus } from './domain/models/PublicationStatus'
37 changes: 37 additions & 0 deletions src/datasets/domain/models/FileTreeNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
export enum FileTreeNodeType {
FOLDER = 'folder',
FILE = 'file'
}

export interface FileTreeFolderNode {
type: FileTreeNodeType.FOLDER
name: string
path: string
counts?: {
files: number
folders: number
}
}

export interface FileTreeFileNode {
type: FileTreeNodeType.FILE
id: number
name: string
path: string
size: number
contentType?: string
access?: 'public' | 'restricted' | 'embargoed'
checksum?: {
type: string
value: string
}
downloadUrl: string
}

export type FileTreeNode = FileTreeFolderNode | FileTreeFileNode

export const isFileTreeFolderNode = (node: FileTreeNode): node is FileTreeFolderNode =>
node.type === FileTreeNodeType.FOLDER

export const isFileTreeFileNode = (node: FileTreeNode): node is FileTreeFileNode =>
node.type === FileTreeNodeType.FILE
22 changes: 22 additions & 0 deletions src/datasets/domain/models/FileTreePage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { FileTreeNode } from './FileTreeNode'

export enum FileTreeInclude {
ALL = 'all',
FOLDERS = 'folders',
FILES = 'files'
}

export enum FileTreeOrder {
NAME_AZ = 'NameAZ',
NAME_ZA = 'NameZA'
}

export interface FileTreePage {
path: string
items: FileTreeNode[]
nextCursor: string | null
limit: number
order: FileTreeOrder
include: FileTreeInclude
approximateCount?: number
}
14 changes: 14 additions & 0 deletions src/datasets/domain/repositories/IDatasetsRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,19 @@ import { DatasetLicenseUpdateRequest } from '../dtos/DatasetLicenseUpdateRequest
import { DatasetTypeDTO } from '../dtos/DatasetTypeDTO'
import { StorageDriver } from '../models/StorageDriver'
import { DatasetUploadLimits } from '../models/DatasetUploadLimits'
import { FileTreePage, FileTreeInclude, FileTreeOrder } from '../models/FileTreePage'

export interface ListDatasetTreeNodeParams {
datasetId: number | string
datasetVersionId?: string
path?: string
limit?: number
cursor?: string
include?: FileTreeInclude
order?: FileTreeOrder
includeDeaccessioned?: boolean
originals?: boolean
}

export interface IDatasetsRepository {
getDataset(
Expand Down Expand Up @@ -104,4 +117,5 @@ export interface IDatasetsRepository {
): Promise<void>
getDatasetStorageDriver(datasetId: number | string): Promise<StorageDriver>
getDatasetUploadLimits(datasetId: number | string): Promise<DatasetUploadLimits>
listDatasetTreeNode(params: ListDatasetTreeNodeParams): Promise<FileTreePage>
}
30 changes: 30 additions & 0 deletions src/datasets/domain/useCases/IterateDatasetTreeNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { IDatasetsRepository, ListDatasetTreeNodeParams } from '../repositories/IDatasetsRepository'
import { FileTreeNode } from '../models/FileTreeNode'

/**
* Async generator that exhaustively iterates the immediate children of the
* given path inside a dataset version, transparently following the
* `nextCursor` chain.
*
* Use this when you need every direct child of a folder; it does NOT recurse
* into subfolders — that is the caller's responsibility (e.g. pre-download
* enumeration walks the tree by re-invoking this iterator with each folder
* path it discovers).
*/
export class IterateDatasetTreeNode {
constructor(private readonly datasetsRepository: IDatasetsRepository) {}

async *execute(params: ListDatasetTreeNodeParams): AsyncGenerator<FileTreeNode> {
let cursor = params.cursor
do {
const page = await this.datasetsRepository.listDatasetTreeNode({
...params,
cursor
})
for (const item of page.items) {
yield item
}
cursor = page.nextCursor ?? undefined
} while (cursor)
}
}
20 changes: 20 additions & 0 deletions src/datasets/domain/useCases/ListDatasetTreeNode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { UseCase } from '../../../core/domain/useCases/UseCase'
import { IDatasetsRepository, ListDatasetTreeNodeParams } from '../repositories/IDatasetsRepository'
import { FileTreePage } from '../models/FileTreePage'

export class ListDatasetTreeNode implements UseCase<FileTreePage> {
constructor(private readonly datasetsRepository: IDatasetsRepository) {}

/**
* Lists the immediate children of the given folder path inside a dataset
* version, returning a single page of folders and files.
*
* Folders are returned first, then files. Both are sorted by name. Use the
* returned `nextCursor` to keep paging the same folder. The cursor is
* opaque to callers and is server-validated; an invalid cursor yields a 400
* from the API.
*/
async execute(params: ListDatasetTreeNodeParams): Promise<FileTreePage> {
return this.datasetsRepository.listDatasetTreeNode(params)
}
}
18 changes: 17 additions & 1 deletion src/datasets/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@ import { UpdateTermsOfAccess } from './domain/useCases/UpdateTermsOfAccess'
import { UpdateDatasetLicense } from './domain/useCases/UpdateDatasetLicense'
import { GetDatasetStorageDriver } from './domain/useCases/GetDatasetStorageDriver'
import { GetDatasetUploadLimits } from './domain/useCases/GetDatasetUploadLimits'
import { ListDatasetTreeNode } from './domain/useCases/ListDatasetTreeNode'
import { IterateDatasetTreeNode } from './domain/useCases/IterateDatasetTreeNode'

const datasetsRepository = new DatasetsRepository()

Expand Down Expand Up @@ -86,6 +88,8 @@ const updateTermsOfAccess = new UpdateTermsOfAccess(datasetsRepository)
const updateDatasetLicense = new UpdateDatasetLicense(datasetsRepository)
const getDatasetStorageDriver = new GetDatasetStorageDriver(datasetsRepository)
const getDatasetUploadLimits = new GetDatasetUploadLimits(datasetsRepository)
const listDatasetTreeNode = new ListDatasetTreeNode(datasetsRepository)
const iterateDatasetTreeNode = new IterateDatasetTreeNode(datasetsRepository)

export {
getDataset,
Expand Down Expand Up @@ -118,7 +122,9 @@ export {
deleteDatasetType,
updateDatasetLicense,
getDatasetStorageDriver,
getDatasetUploadLimits
getDatasetUploadLimits,
listDatasetTreeNode,
iterateDatasetTreeNode
}
export { DatasetNotNumberedVersion } from './domain/models/DatasetNotNumberedVersion'
export { DatasetUserPermissions } from './domain/models/DatasetUserPermissions'
Expand Down Expand Up @@ -159,3 +165,13 @@ export { DatasetType } from './domain/models/DatasetType'
export { DatasetTypeDTO } from './domain/dtos/DatasetTypeDTO'
export { StorageDriver } from './domain/models/StorageDriver'
export { DatasetUploadLimits } from './domain/models/DatasetUploadLimits'
export {
FileTreeNode,
FileTreeFolderNode,
FileTreeFileNode,
FileTreeNodeType,
isFileTreeFolderNode,
isFileTreeFileNode
} from './domain/models/FileTreeNode'
export { FileTreePage, FileTreeInclude, FileTreeOrder } from './domain/models/FileTreePage'
export { ListDatasetTreeNodeParams } from './domain/repositories/IDatasetsRepository'
37 changes: 36 additions & 1 deletion src/datasets/infra/repositories/DatasetsRepository.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { ApiRepository } from '../../../core/infra/repositories/ApiRepository'
import { IDatasetsRepository } from '../../domain/repositories/IDatasetsRepository'
import {
IDatasetsRepository,
ListDatasetTreeNodeParams
} from '../../domain/repositories/IDatasetsRepository'
import { DatasetNotNumberedVersion } from '../../domain/models/DatasetNotNumberedVersion'
import { FileTreeInclude, FileTreeOrder, FileTreePage } from '../../domain/models/FileTreePage'
import { transformTreeResponseToFileTreePage } from './transformers/fileTreeTransformers'
import { Dataset, VersionUpdateType } from '../../domain/models/Dataset'
import {
transformVersionResponseToDataset,
Expand Down Expand Up @@ -523,4 +529,33 @@ export class DatasetsRepository extends ApiRepository implements IDatasetsReposi
throw error
})
}

public async listDatasetTreeNode(params: ListDatasetTreeNodeParams): Promise<FileTreePage> {
const versionId = params.datasetVersionId ?? DatasetNotNumberedVersion.LATEST
const queryParams: Record<string, string | number | boolean> = {}
if (params.path !== undefined) queryParams.path = params.path
if (params.limit !== undefined) queryParams.limit = params.limit
if (params.cursor !== undefined) queryParams.cursor = params.cursor
queryParams.include = params.include ?? FileTreeInclude.ALL
queryParams.order = params.order ?? FileTreeOrder.NAME_AZ
if (params.includeDeaccessioned !== undefined) {
queryParams.includeDeaccessioned = params.includeDeaccessioned
}
if (params.originals !== undefined) {
queryParams.originals = params.originals
}
return this.doGet(
this.buildApiEndpoint(
this.datasetsResourceName,
`versions/${versionId}/tree`,
params.datasetId
),
true,
queryParams
)
.then((response) => transformTreeResponseToFileTreePage(response))
.catch((error) => {
throw error
})
}
}
Loading
Loading