diff --git a/README.md b/README.md index 0c233c7..9725773 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,9 @@ models a full, versioned, access-controlled filesystem on top of Swarm's content address. - **Versioning** — every structural change publishes a new feed slot, so drives, folders and files all gain automatic history. Restore any file version to head. -- **Trash / recover / forget** — soft-delete via an owner-private overlay, or hard-delete a node from the manifest. -- **Move** — relocate files and folders within or across drives. +- **Trash / recover / forget** — soft-delete by relocating a node into the drive's reserved `.trash` folder, or + hard-delete it from the manifest. +- **Move** — relocate files and folders within a drive. - **Browser + Node.js** — one unified API; the byte source differs (`file` vs `sourcePath`). > Full method-level documentation: see [REFERENCE.md](REFERENCE.md). Test coverage and usage patterns: see @@ -57,17 +58,17 @@ flowchart TD ### The UNIX mapping -| Swarm / mantaray primitive | UNIX filesystem analogue | Role | -| -------------------------------------------- | --------------------------- | ------------------------------------------------ | -| Signer (Ethereum identity) | volume owner | Owns and signs the whole tree | -| State feed head (`FILEMANAGER_STATE_TOPIC`) | root pointer | Resolves the current drive registry | -| Admin manifest | volume table (`/etc/fstab`) | Registry of all drives | -| Drive = mantaray under a per-drive feed | mounted volume | A named, stamp-backed collection | -| Folder = sub-manifest fork | directory (inode) | Nested namespace | -| File fork → per-file feed | file (inode) with history | Stable identity + full version chain | -| Fork metadata map (`swarm-node-*`) | inode metadata | Owner, type, version, ACT publisher, path | -| New feed slot on every structural change | filesystem snapshot | Automatic drive/folder/file version history | -| Trash overlay (owner-private admin metadata) | recycle bin / `.Trash` | Soft-delete without mutating data or the subtree | +| Swarm / mantaray primitive | UNIX filesystem analogue | Role | +| ------------------------------------------- | --------------------------- | ------------------------------------------------ | +| Signer (Ethereum identity) | volume owner | Owns and signs the whole tree | +| State feed head (`FILEMANAGER_STATE_TOPIC`) | root pointer | Resolves the current drive registry | +| Admin manifest | volume table (`/etc/fstab`) | Registry of all drives | +| Drive = mantaray under a per-drive feed | mounted volume | A named, stamp-backed collection | +| Folder = sub-manifest fork | directory (inode) | Nested namespace | +| File fork → per-file feed | file (inode) with history | Stable identity + full version chain | +| Fork metadata map (`swarm-node-*`) | inode metadata | Owner, type, version, ACT publisher, path | +| New feed slot on every structural change | filesystem snapshot | Automatic drive/folder/file version history | +| Reserved `.trash` folder per drive | recycle bin / `.Trash` | Soft-delete without mutating data or the subtree | ### Key design points @@ -120,7 +121,7 @@ swarm-cli stamp buy --amount 100000000000 --depth 20 --label admin ## Quick Start ```ts -import { Bee } from '@ethersphere/bee-js'; +import { Bee, FeedIndex } from '@ethersphere/bee-js'; import { FileManagerBase, ListDepth } from '@solarpunkltd/file-manager-lib'; // bee must be constructed with a signer @@ -159,7 +160,8 @@ const record = fm.recordList.find((r) => r.path === 'docs/readme.md')!; const { result } = await fm.downloadFile(record); // 7. re-version, move, restore -const v0 = await fm.getFileVersion(record, '0'); +// a version is a feed slot index — pass a FeedIndex, not a plain number string +const v0 = await fm.getFileVersion(record, FeedIndex.fromBigInt(0n)); await fm.restoreFileVersion(v0); await fm.move('docs/readme.md', 'docs/README.md', drive.id); ``` @@ -197,9 +199,10 @@ fm.emitter.on(FileManagerEvents.FILE_UPLOADED, ({ record }) => console.log('uplo ``` `INITIALIZED`, `STATE_INVALID`, `DRIVE_CREATED`, `DRIVE_FORGOTTEN`, `FILE_UPLOADED`, `FILES_UPLOADED`, `FILE_UPDATED`, -`FILE_DOWNLOADED`, `FILE_MOVED`, `FILE_TRASHED`, `FILE_RECOVERED`, `FILE_FORGOTTEN`, `FILE_VERSION_RESTORED`, -`FOLDER_CREATED`, `FOLDER_TRASHED`, `FOLDER_RECOVERED`, `FOLDER_FORGOTTEN`. See [REFERENCE.md](REFERENCE.md#events) for -when each fires. +`FILE_MOVED`, `FILE_TRASHED`, `FILE_RECOVERED`, `FILE_FORGOTTEN`, `FILE_VERSION_RESTORED`, `FOLDER_CREATED`, +`FOLDER_MOVED`, `FOLDER_TRASHED`, `FOLDER_RECOVERED`, `FOLDER_FORGOTTEN`, `TRASH_EMPTIED`. Path-addressed operations +(`move`, `trash`, `recover`, `forget`) emit the file or folder variant with the same payload shape. See +[REFERENCE.md](REFERENCE.md#events) for each payload. --- diff --git a/REFERENCE.md b/REFERENCE.md index e18a778..998d841 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -18,7 +18,7 @@ method for cancellation (`signal`) and retries; it is omitted from the descripti - [Files — read](#files--read) — `downloadFile`, `downloadFiles`, `downloadFolder` - [Folders](#folders) — `createFolder`, `listFolder`, `move`, `forget` - [Versioning](#versioning) — `getFileVersion`, `restoreFileVersion` -- [Trash](#trash) — `trashFile`, `recoverFile`, `trashFolder`, `recoverFolder`, `listTrash` +- [Trash](#trash) — `trash`, `recover`, `listTrash`, `emptyTrash` - [Getters](#getters) — `adminStamp`, `driveList`, `recordList`, `emitter`, `isInitialized` - [Events](#events) - [Types](#types) @@ -40,8 +40,8 @@ constructor(bee: Bee, emitter?: EventEmitter, config?: FileManagerConfig) omitted. - **config** _(optional)_ — concurrency tuning, see [`FileManagerConfig`](#filemanagerconfig). -Wraps the Bee client and owns the on-Swarm drive/folder/file tree, ACT wrapping, per-file version feeds, the -owner-private trash overlay, and event emission. +Wraps the Bee client and owns the on-Swarm drive/folder/file tree, ACT wrapping, per-file version feeds, the trash +relocation, and event emission. ### `FileManagerConfig` @@ -127,9 +127,17 @@ multi-file/folder uploads use - **uploadOptions?** `RedundantUploadOptions | FileUploadOptions`. - **Returns**: the newly-created `FileRecord`. - **Emits**: `FILE_UPLOADED`. -- **Throws**: `DriveError` (not initialized, drive not found, or target folder path missing); `SignerError`; `FileError` - (source is a directory, node source path missing, or content upload failed); `FileRecordError` (a folder along the - path has no feed). +- **Throws**: `DriveError` (not initialized, drive not found, target folder path missing, or a node already occupies + `item.path`); `FolderError` (the path is under the reserved `.trash` folder); `SignerError`; `FileError` (source is a + directory, node source path missing, or content upload failed); `FileRecordError` (invalid `item.path`, or a folder + along the path has no feed). + +Names are fork keys, so they are unique within a folder: uploading onto an occupied name is rejected rather than +silently replacing it. Re-version with +[`updateFile`](#updatefiledriveid-record-changes-uploadoptions-requestoptions-promisefilerecord), relocate with +[`move`](#movefrompath-topath-sourcedriveid-requestoptions-promisevoid), or drop the existing node with +[`forget`](#forgetdriveid-path-requestoptions-promisevoid) first. `item.path` must have a non-empty leaf and no `.`/`..` +segments; it is validated before any content is uploaded. ### `uploadFiles(driveId, items, destinationPath?, uploadOptions?, requestOptions?): Promise` @@ -139,26 +147,39 @@ created as needed; each touched parent manifest is saved once at the end. **Part are collected, not thrown. - **items** [`UploadItem[]`](#uploaditem) — each with a `path` relative to `destinationPath`. -- **destinationPath?** — absolute destination folder, or `'/'` for the drive root. +- **destinationPath?** — absolute destination folder; defaults to the drive root. - **Returns**: [`UploadFilesResult`](#uploadfilesresult) — `{ succeeded, failed }`. - **Emits**: `FOLDER_CREATED` (per folder created), `FILE_UPLOADED` (per file), `FILES_UPLOADED` (once, batch summary). -- **Throws**: `FileRecordError` (no items, invalid item path, or malformed folder fork); `DriveError` (not initialized, - drive not found, or a path segment is a file); `SignerError`. Per-file content-upload failures go into `failed`. + All of them fire **after** the last manifest is saved, so an emitted node is always in the drive tree — the batch is + never announced in instalments, and a failed finalize emits nothing but `FILES_UPLOADED`'s absence. Upload events are + therefore not a progress feed; use the returned `succeeded` / `failed` for outcomes. +- **Throws**: `FileRecordError` (no items, invalid item path, two items resolving to the same destination, or a + malformed folder fork); `DriveError` (not initialized, drive not found, or a path segment is a file); `FolderError` (a + destination is under the reserved `.trash` folder); `SignerError`. Per-file content-upload failures go into `failed`, + as does an item whose destination name is already taken. + +Existing folders along the way are reused; existing **files** are not overwritten (see `uploadFile` above). + +**Abort semantics.** Aborting wins immediately: the batch stops starting files, no manifest is saved, and the call +rejects. The batch's own in-memory state is discarded with it — its records were never committed to `recordList` and +every manifest it mutated is evicted from the store — so the drive is left exactly as it was and nothing from the +aborted batch can be committed later by an unrelated save. A finalize failure is treated the same way. Content and +record feeds written before the abort are spent but unreferenced; re-upload those files to place them. ### `updateFile(driveId, record, changes, uploadOptions?, requestOptions?): Promise` Re-versions or changes metadata of an **existing** file. Reuses the file's feed topic, writes a new feed slot, and never -touches the drive manifest (no rename — use -[`move`](#movefrompath-topath-sourcedriveid-targetdriveid-requestoptions-promisevoid) to relocate). Everything derives -from `record`, including the ACT-history continuation reference. +touches the drive manifest (no rename — use [`move`](#movefrompath-topath-sourcedriveid-requestoptions-promisevoid) to +relocate). Everything derives from `record`, including the ACT-history continuation reference. - **record** — the existing file's `FileRecord` (the single source of truth). - **changes** [`UpdateItem`](#updateitem) — `item` present ⇒ new bytes; absent ⇒ metadata-only. `customMetadata` is merged over the record's existing metadata. - **Returns**: the newly-written `FileRecord` for the updated version. - **Emits**: `FILE_UPDATED`. -- **Throws**: `FileRecordError` (neither new content nor `customMetadata` provided); `DriveError`; `SignerError`; - `FileError` (content upload failed). +- **Throws**: `FileRecordError` (neither new content nor `customMetadata` provided, the file is trashed, or the fork + belongs to another node); `DriveError`; `FolderError` (no fork at the record's path); `SignerError`; `FileError` + (content upload failed). --- @@ -187,8 +208,8 @@ passed records. Downloads every file in a folder subtree, resolved fresh via `listFolder`. `path` omitted ⇒ the whole drive. - **Returns**: one `DownloadFilesResult` marking per file success and failure in the subtree. -- **Throws**: `DriveError` (not initialized, drive not found, or folder path missing); `SignerError`; `FileRecordError` - (a folder feed is missing). Per-file failures are logged. +- **Throws**: `DriveError` (not initialized, drive not found, or folder path missing); `FolderError` (`path` is the + reserved `.trash` folder); `SignerError`; `FileRecordError` (a folder feed is missing). Per-file failures are logged. --- @@ -199,34 +220,41 @@ Downloads every file in a folder subtree, resolved fresh via `listFolder`. `path Creates a new empty folder (a nested mantaray) within a drive. - **parentPath** — absolute path of the parent, or `'/'` for the drive root. -- **folderName** — must not contain `/`. +- **folderName** — must not contain `/`, and must not already be taken by a file or folder in the parent. - **redundancyLevel?** — inherits from parent or drive if omitted. - **Returns**: the new `FolderInfo`. - **Emits**: `FOLDER_CREATED`. -- **Throws**: `DriveError` (not initialized, drive not found, invalid name, or parent path missing); `SignerError`; - `FileRecordError` (a folder feed is missing). +- **Throws**: `DriveError` (not initialized, drive not found, or parent path missing); `FolderError` (invalid or + reserved name, or the name is already taken); `SignerError`; `FileRecordError` (a folder feed is missing). + +`mkdir` semantics, not upsert: a duplicate name is rejected before a feed is minted for it. `uploadFiles` differs +deliberately — it reuses an existing folder on the way to a file rather than failing. ### `listFolder(driveId, path, depth?, maxDepth?, requestOptions?): Promise` Lists entries in a folder (or drive root) from the drive manifest, hydrating and caching any file entries into -`recordList`. Trashed nodes (and everything under a trashed folder) are hidden. +`recordList`. The reserved `.trash` folder is omitted from the drive root and cannot be listed here — use `listTrash`. - **path** — absolute folder path, or `'/'` for the drive root. - **depth?** [`ListDepth`](#enums) — `Shallow` (one level, default) or `Deep` (full BFS). -- **maxDepth?** — max BFS levels when `Deep`; unlimited if omitted. +- **maxDepth?** — max BFS levels when `Deep`; must be positive, unlimited if omitted. - **Returns**: [`NodeEntry[]`](#nodeentry) (`FileRecord | FolderInfo`) for every node at or below `path`. -- **Throws**: `DriveError` (not initialized, drive not found, or a path segment missing); `SignerError`; - `FileRecordError` (a folder feed is missing). +- **Throws**: `DriveError` (not initialized, drive not found, or a path segment missing); `FolderError` (`path` is the + reserved `.trash` folder, or `maxDepth` is not positive); `SignerError`; `FileRecordError` (a folder feed is missing). -### `move(fromPath, toPath, sourceDriveId, targetDriveId?, requestOptions?): Promise` +### `move(fromPath, toPath, sourceDriveId, requestOptions?): Promise` -Moves a file or folder from one path to another, within a drive or across drives. Path-addressed and dispatches on node -type, so it works for both files and folders. +Moves a file or folder from one path to another **within a single drive**. Path-addressed and dispatches on node type, +so it works for both files and folders. -- **targetDriveId?** — for cross-drive moves; defaults to `sourceDriveId`. - **Emits**: `FILE_MOVED`. -- **Throws**: `DriveError` (not initialized, source/target drive not found, source is root, invalid destination, source - == destination, or a path missing); `SignerError`; `FileRecordError` (a folder feed or the source record is missing). +- **Throws**: `DriveError` (not initialized, drive not found, or a folder along either path missing); `FolderError` + (source is root, invalid destination, source == destination, source not found, destination occupied, or either path + under `.trash`); `SignerError`; `FileRecordError` (a folder feed or the source record is missing). + +There is no cross-drive move: a relocated node keeps its drive's `batchId`, so a file "in" another drive would still be +paid for — and die — with the original stamp. Both paths are resolved against `sourceDriveId`, so a path from another +drive simply is not found. To relocate content between drives, `forget` it and re-upload to the target. ### `forget(driveId, path, requestOptions?): Promise` @@ -235,8 +263,9 @@ type, so it works for both files and folders. removed from the tree. - **Emits**: `FILE_FORGOTTEN` (file) or `FOLDER_FORGOTTEN` (folder). -- **Throws**: `DriveError` (not initialized, drive not found, path is the drive root, or path missing); `SignerError`; - `FileRecordError` (a folder feed is missing). +- **Throws**: `DriveError` (not initialized, drive not found, or a folder along the path missing); `FolderError` (path + is the drive root, or `.trash` itself — use `emptyTrash`); `SignerError`; `FileRecordError` (path not found, or a + folder feed is missing). --- @@ -249,9 +278,11 @@ structural change publishes a new manifest slot. Returns a specific version of a file. -- **record** — base `FileRecord` (provides `topic` + `owner`). -- **version?** `string | FeedIndex` — desired slot; latest if omitted. -- **Returns**: the `FileRecord` for that version (cached or fetched). +- **record** — base `FileRecord` (provides `topic`, `owner` and the node's current path). +- **version?** `string | FeedIndex` — desired slot; latest if omitted. A `string` must be the 16-hex-character + `FeedIndex` form (`FeedIndex.fromBigInt(0n).toString()`), not a decimal like `'0'`. +- **Returns**: the `FileRecord` for that version (cached or fetched). Its `path` is the node's **current** absolute + location, not the leaf stored in the requested slot — restoring a version restores content, never location. - **Throws**: `DriveError` (not initialized); `SignerError`; `FileRecordError` (file feed not found). ### `restoreFileVersion(versionToRestore, requestOptions?): Promise` @@ -260,54 +291,66 @@ Restores a previous version as the new head of the file's feed. Per-file only folder/drive-level restore. - **Emits**: `FILE_VERSION_RESTORED`. -- **Throws**: `DriveError` (not initialized); `SignerError`; `FileRecordError` (feed not found, restore version - undefined, or it is already the current head). +- **Throws**: `DriveError` (not initialized, or no fork at the file's current path); `SignerError`; `FileRecordError` + (feed not found, restore version undefined, it is already the current head, or the fork at that path belongs to a + different node). --- ## Trash -Soft-delete is an **owner-private overlay** on the drive's admin metadata (`trashedNodes`), keyed by node topic. It -never touches a node's own feed, content, or subtree, and is not visible to anyone but the owner. Status is derived from -the overlay, not persisted onto records. +Trash is a **reserved `.trash` folder** at the drive root, not a metadata overlay. Trashing relocates a node's fork into +it — keyed by the node's own topic, so same-named nodes never collide — and stamps the path it came from onto the moved +fork. The node's feed, version and content are untouched, and a folder's subtree rides along unread, so any trash or +recover is two manifest writes regardless of depth. -### `trashFile(record, requestOptions?): Promise` +Trashed nodes leave the active namespace completely: `listFolder` omits `.trash` from the drive root and refuses to +descend into it, `downloadFolder` skips trashed files, and `updateFile` / `uploadFile` / `createFolder` / `move` refuse +any path under `.trash`. The folder is created lazily on the first trash, so a drive that never trashes anything carries +no trash node at all. -Soft-deletes a file: records it in the trash overlay so it is hidden from the active list. Metadata-only. +### `trash(driveId, path, requestOptions?): Promise` -- **Emits**: `FILE_TRASHED`. -- **Throws**: `DriveError` (not initialized or drive not found); `SignerError`; `FileRecordError` (already trashed). +Soft-deletes the file or folder at `path`. Bare and path-addressed — it dispatches on the resolved node type. -### `recoverFile(record, requestOptions?): Promise` +- **Emits**: `FILE_TRASHED` or `FOLDER_TRASHED`, with `{ driveId, path, trashedPath }` (plus `record` for a file). +- **Throws**: `DriveError` (not initialized, drive not found, or a folder along the path missing); `FolderError` (path + is the drive root, already under `.trash`, or the node itself not found); `SignerError`; `FileRecordError` (fork + missing node metadata). -Recovers a trashed file back into the active list (removes it from the overlay). +### `recover(driveId, trashedPath, toPath?, requestOptions?): Promise` -- **Emits**: `FILE_RECOVERED`. -- **Throws**: `DriveError`; `SignerError`; `FileRecordError` (not currently trashed). +Restores a trashed node to `toPath`, or to the location stamped on it when `toPath` is omitted. Restores **location +only** — content and version are whatever they were. -### `trashFolder(folder, requestOptions?): Promise` +The stamped origin can go stale: if that folder has since been forgotten, moved or trashed, resolution fails and the +caller passes an explicit `toPath`. An occupied destination is refused, never overwritten. -Soft-deletes a folder by recording **only the folder's own topic** — no propagation. The subtree is untouched and costs -a single overlay entry regardless of depth. `listFolder` hides the folder and stops descending; contents reappear on -recover. +- **Returns**: the path the node was restored to. +- **Emits**: `FILE_RECOVERED` or `FOLDER_RECOVERED`. +- **Throws**: `DriveError` (destination occupied, or the destination's parent no longer exists); `FolderError` + (destination under `.trash`); `SignerError`; `FileRecordError` (`trashedPath` is not `.trash/`, invalid + destination path, not in the trash, or no stamped origin and no `toPath`). -- **Emits**: `FOLDER_TRASHED`. -- **Throws**: `DriveError`; `SignerError`; `FileRecordError` (already trashed). +### `listTrash(driveId, depth?, maxDepth?, requestOptions?): Promise` -### `recoverFolder(folder, requestOptions?): Promise` +Walks `.trash` with the same machinery as `listFolder`, so `depth` controls the cost: `Shallow` (default) returns the +trashed roots only, `Deep` descends into trashed folders. Returns `[]` for a drive with no trash node. -Recovers a trashed folder (removes its topic from the overlay); its never-modified subtree becomes visible again. +Entries carry `status = trashed`, `path` = their real location under `.trash`, and `trashedFrom` = where they came from. -- **Emits**: `FOLDER_RECOVERED`. -- **Throws**: `DriveError`; `SignerError`; `FileRecordError` (not currently trashed). +- **maxDepth?** — max BFS levels when `Deep`; must be positive, unlimited if omitted. +- **Returns**: the trashed nodes; pass a `path` back to `recover`. +- **Throws**: `DriveError` (not initialized or drive not found); `FolderError` (`maxDepth` is not positive); + `SignerError`. -### `listTrash(driveId, requestOptions?): Promise` +### `emptyTrash(driveId, requestOptions?): Promise` -Lists a drive's trashed nodes (files and folders), hydrated into full `NodeEntry` objects with `status = trashed`. Reads -straight from the overlay with no tree walk, so cost is proportional to the number of trashed roots, not drive size. -Recovery is honored per topic, so visibility also requires ancestors to be recovered. +De-references every trashed node in one manifest write. Like `forget`, the content stays on Swarm until its stamp +expires — this drops references, it does not delete data. -- **Returns**: the trashed files and folders; pass one back to `recoverFile` / `recoverFolder`. +- **Returns**: how many nodes were de-referenced. +- **Emits**: `TRASH_EMPTIED`. - **Throws**: `DriveError` (not initialized or drive not found); `SignerError`. --- @@ -330,25 +373,36 @@ Both list getters are `readonly` — treat them as snapshots and mutate state on Emitted on the provided `EventEmitter` as `FileManagerEvents`: -| Event | Fired by | -| ----------------------- | -------------------------------------------------- | -| `INITIALIZED` | `initialize` (success) | -| `STATE_INVALID` | `initialize` (unparseable state) | -| `DRIVE_CREATED` | `createAdminDrive`, `createDrive` | -| `DRIVE_FORGOTTEN` | `forgetDrive` | -| `FILE_UPLOADED` | `uploadFile`, `uploadFiles` (per file) | -| `FILES_UPLOADED` | `uploadFiles` (once, batch summary) | -| `FILE_UPDATED` | `updateFile` | -| `FILE_DOWNLOADED` | download path | -| `FILE_MOVED` | `move` | -| `FILE_TRASHED` | `trashFile` | -| `FILE_RECOVERED` | `recoverFile` | -| `FILE_FORGOTTEN` | `forget` (file) | -| `FILE_VERSION_RESTORED` | `restoreFileVersion` | -| `FOLDER_CREATED` | `createFolder`, `uploadFiles` (per folder created) | -| `FOLDER_TRASHED` | `trashFolder` | -| `FOLDER_RECOVERED` | `recoverFolder` | -| `FOLDER_FORGOTTEN` | `forget` (folder) | +| Event | Fired by | Payload | +| ----------------------- | -------------------------------------------------- | ---------------------------------------------------- | +| `INITIALIZED` | `initialize` (success or failure) | `boolean` | +| `STATE_INVALID` | `initialize` (unparseable state) | `boolean` | +| `DRIVE_CREATED` | `createAdminDrive`, `createDrive` | `{ driveInfo }` | +| `DRIVE_FORGOTTEN` | `forgetDrive` | `{ driveInfo }` | +| `FILE_UPLOADED` | `uploadFile`, `uploadFiles` (per file) | `{ record }` | +| `FILES_UPLOADED` | `uploadFiles` (once, batch summary) | `{ succeeded, failed }` | +| `FILE_UPDATED` | `updateFile` | `{ record }` | +| `FILE_VERSION_RESTORED` | `restoreFileVersion` | `{ restored }` | +| `FILE_MOVED` | `move` (file) | `{ driveId, fromPath, toPath, record }` | +| `FOLDER_MOVED` | `move` (folder) | `{ driveId, fromPath, toPath, folderInfo }` | +| `FILE_TRASHED` | `trash` (file) | `{ driveId, path, trashedPath, record }` | +| `FOLDER_TRASHED` | `trash` (folder) | `{ driveId, path, trashedPath, folderInfo }` | +| `FILE_RECOVERED` | `recover` (file) | `{ driveId, trashedPath, restoredPath, record }` | +| `FOLDER_RECOVERED` | `recover` (folder) | `{ driveId, trashedPath, restoredPath, folderInfo }` | +| `FILE_FORGOTTEN` | `forget` (file) | `{ driveId, path, record }` | +| `FOLDER_FORGOTTEN` | `forget` (folder) | `{ driveId, path, folderInfo }` | +| `FOLDER_CREATED` | `createFolder`, `uploadFiles` (per folder created) | `{ folderInfo }` | +| `TRASH_EMPTIED` | `emptyTrash` | `{ driveId, count }` | + +`move` / `trash` / `recover` / `forget` are path-addressed and dispatch on node type, so each emits a file **or** folder +event whose payloads are the same shape: the drive id, the operation's paths, and the node itself — a +[`FileRecord`](#filerecord) as `record` or a [`FolderInfo`](#folderinfo) as `folderInfo`. `record` is `undefined` when +the file was never hydrated into `recordList`; `folderInfo` is composed from the fork's metadata, so it carries no +`manifestRef`. + +Every event fires only after the Swarm writes behind it have landed, so a received event always describes committed +state, never an operation still in flight — a failed operation rejects and emits nothing. Consequently events are not a +progress feed; for batch progress use the `succeeded` / `failed` result. --- @@ -408,7 +462,7 @@ interface FileRecord extends NodeResource { ### `DriveInfo` -A drive = a mantaray host with an id, name, and the owner-private trash overlay. +A drive = a mantaray host with an id and a name. ```ts interface DriveInfo extends ManifestHost { @@ -416,7 +470,6 @@ interface DriveInfo extends ManifestHost { id: string; name: string; isAdmin: boolean; - trashedNodes?: TrashEntry[]; } ``` @@ -429,6 +482,7 @@ interface FolderInfo extends ManifestHost { type: NodeType.Folder; path: string; driveId: string; + trashedFrom?: string; } ``` @@ -450,17 +504,6 @@ interface ManifestHost extends NodeResource { type NodeEntry = FileRecord | FolderInfo; // discriminate on `.type` ``` -### `TrashEntry` - -```ts -interface TrashEntry { - topic: string; - type: NodeType; - path: string; - version?: string; -} -``` - ### `UploadItem` Upload metadata plus the environment-specific byte source. `topic` is intentionally absent (a new topic is minted). @@ -544,7 +587,7 @@ Each manifest fork carries a metadata map that mirrors inode metadata. Keys are | `MANIFEST_METADATA_DRIVE_IS_ADMIN` | `swarm-drive-is-admin` | drive | Admin-drive flag | | `MANIFEST_METADATA_DRIVE_BATCH_ID` | `swarm-drive-batch-id` | drive | Backing postage batch | | `MANIFEST_METADATA_DRIVE_ACT_PUBLISHER` | `swarm-drive-act-publisher` | drive | Drive-level ACT publisher | -| `MANIFEST_METADATA_DRIVE_TRASHED_NODES` | `swarm-drive-trashed-nodes` | drive | Serialised owner-private trash overlay | +| `MANIFEST_METADATA_TRASHED_FROM` | `swarm-trashed-from` | trash | Path the node was trashed from | --- @@ -553,15 +596,15 @@ Each manifest fork carries a metadata map that mirrors inode metadata. Keys are All errors extend `FileManagerError` (which sets an explicit `.name` and supports an ES2022 `cause`), so consumers can catch broadly (`instanceof FileManagerError`) or branch on `error.name`. -| Error | Meaning | -| ----------------- | ------------------------------------------------------------------------ | -| `DriveError` | Drive creation, lookup, or destruction problems (incl. not-initialized). | -| `FolderError` | Folder-operation failures. | -| `FileError` | Content/IO failures — reading, uploading, or downloading file bytes. | -| `FileRecordError` | Record / feed / metadata failures (missing feed, invalid version, etc.). | -| `StampError` | Postage stamp missing or not usable. | -| `SignerError` | Signer / publisher unavailable. | -| `BeeVersionError` | Connected Bee node version is unsupported. | +| Error | Meaning | +| ----------------- | ------------------------------------------------------------------------------- | +| `DriveError` | Drive creation, lookup, or destruction problems (incl. not-initialized). | +| `FolderError` | Folder-operation failures — invalid names/paths, collisions, reserved `.trash`. | +| `FileError` | Content/IO failures — reading, uploading, or downloading file bytes. | +| `FileRecordError` | Record / feed / metadata failures (missing feed, invalid version, etc.). | +| `StampError` | Postage stamp missing or not usable. | +| `SignerError` | Signer / publisher unavailable. | +| `BeeVersionError` | Connected Bee node version is unsupported. | --- diff --git a/src/eventEmitter/eventEmitter.ts b/src/eventEmitter/eventEmitter.ts index 158cad0..9d3d1d7 100644 --- a/src/eventEmitter/eventEmitter.ts +++ b/src/eventEmitter/eventEmitter.ts @@ -1,4 +1,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ +import { Logger } from '../utils/logger'; + +const logger = Logger.getInstance(); + type Listener = (data: T) => void; interface Events { @@ -43,6 +47,13 @@ export class EventEmitterBase implements EventEmitter { public emit(event: string, data: T): void { if (!this.events[event]) return; - this.events[event].forEach((listener) => listener(data)); + this.events[event].forEach((listener) => { + try { + listener(data); + } catch (err: unknown) { + const message = err instanceof Error ? `${err.name}: ${err.message}` : String(err); + logger.error(`EventEmitter: listener for "${event}" threw and was ignored: ${message}`); + } + }); } } diff --git a/src/fileManager.ts b/src/fileManager.ts index a98aaec..cf638b9 100644 --- a/src/fileManager.ts +++ b/src/fileManager.ts @@ -29,7 +29,7 @@ import { type NodeHeader, NodeStatus, NodeType, - type TrashEntry, + type ResolvedFileFork, } from './types/info'; import { type UpdateItem, type UploadFilesResult, type UploadItem } from './types/upload'; import { type ActReferences, type FailedResult } from './types/utils'; @@ -43,30 +43,42 @@ import { } from './utils/bee'; import { awaitAllPromisesBounded, getRecordStatus, joinPath, settlePromises } from './utils/common'; import { - ADMIN_STAMP_LABEL, + ADMIN_DRIVE_NAME, FEED_INDEX_ZERO, FILEMANAGER_STATE_TOPIC, MANIFEST_METADATA_FILE_TOPIC, MANIFEST_METADATA_NODE_TOPIC, MANIFEST_METADATA_NODE_TYPE, MANIFEST_METADATA_NODE_VERSION, + MANIFEST_METADATA_TRASHED_FROM, MAX_CONCURRENT_FEED_FETCHES, MAX_CONCURRENT_UPLOADS, ROOT_PATH, + TRASH_FOLDER_NAME, } from './utils/constants'; import { generateRandomBytes } from './utils/crypto'; -import { DriveError, ErrorHandler, FileError, FileRecordError, SignerError } from './utils/errors'; +import { DriveError, ErrorHandler, FileError, FileRecordError, FolderError, SignerError } from './utils/errors'; import { FileManagerEvents } from './utils/events'; import { Logger } from './utils/logger'; import { driveForkMetadata, fileForkMetadata, folderForkMetadata, + folderInfoFromMetadata, getAllNodeEntries, getDriveForkPath, getRlevel, } from './utils/mantaray'; -import { assertValidRelativePath, normalizePath, pathSegments, splitPath } from './utils/path'; +import { + assertNotTrashPath, + assertValidNodePath, + assertValidRelativePath, + isTrashPath, + normalizePath, + pathSegments, + splitPath, + trashPathOf, +} from './utils/path'; import { processDownload } from './download'; import { type EventEmitter, EventEmitterBase } from './eventEmitter'; import { MantarayStore } from './mantarayStore'; @@ -154,14 +166,17 @@ export class FileManagerBase implements FileManager { } this._isInitialized = true; - this.emitter.emit(FileManagerEvents.INITIALIZED, true); } catch (err: unknown) { + this.resetState(); this.errorHandler.handleError(err, 'Failed to initialize FileManager'); - this._isInitialized = false; this.emitter.emit(FileManagerEvents.INITIALIZED, false); + + return; } finally { this.isInitializing = false; } + + this.emitter.emit(FileManagerEvents.INITIALIZED, true); } // --- Drive operations --- @@ -188,14 +203,14 @@ export class FileManagerBase implements FileManager { const batchIdStr = batchId.toString(); const level = redundancyLevel ?? RedundancyLevel.OFF; - this.logger.debug('Creating admin drive with name: ', ADMIN_STAMP_LABEL); + this.logger.debug('Creating admin drive with name: ', ADMIN_DRIVE_NAME); await this.fetchAndSetAdminStamp(batchIdStr, requestOptions); verifyStampUsability(this.adminStamp, batchIdStr); await this.establishAdminState(batchIdStr, level, reset, requestOptions); return this.registerDrive( - { name: ADMIN_STAMP_LABEL, batchId: batchIdStr, isAdmin: true, redundancyLevel: level, publisher }, + { name: ADMIN_DRIVE_NAME, batchId: batchIdStr, isAdmin: true, redundancyLevel: level, publisher }, requestOptions, ); } @@ -250,6 +265,8 @@ export class FileManagerBase implements FileManager { const { driveIx, cachedDrive } = this.findDriveOrThrow(driveId); assertUploadableSource(item); + assertValidNodePath(item.path); + assertNotTrashPath(item.path); // Resolve the parent folder up front so the new fork inherits the parent's redundancy level. const { parentPath, name: filename } = splitPath(item.path); @@ -261,6 +278,16 @@ export class FileManagerBase implements FileManager { requestOptions, ); + const mantarayNode = await this.store.getMantarayNode( + targetHost.topic, + publisher, + targetHost.manifestRef, + requestOptions, + ); + if (mantarayNode.find(filename)) { + throw new DriveError(`Node already exists at "${item.path}" — use updateFile to re-version a file`); + } + const owner = this.signerAddress; const { topic, version } = await getTopicAndVersion(this.bee, owner, undefined, undefined, requestOptions); @@ -292,13 +319,6 @@ export class FileManagerBase implements FileManager { // In-memory copy is the caller-known absolute path — no walk needed here. record.path = item.path; - const mantarayNode = await this.store.getMantarayNode( - targetHost.topic, - publisher, - targetHost.manifestRef, - requestOptions, - ); - mantarayNode.addFork(filename, new Reference(record.topic), fileForkMetadata(record)); const newManifestRef = await this.store.saveMantarayNode(mantarayNode, targetHost, requestOptions); @@ -307,6 +327,7 @@ export class FileManagerBase implements FileManager { this.driveList[driveIx].manifestRef = newManifestRef; } + this.cacheRecord(record); this.emitter.emit(FileManagerEvents.FILE_UPLOADED, { record }); return record; @@ -315,7 +336,7 @@ export class FileManagerBase implements FileManager { async uploadFiles( driveId: string | Identifier, items: UploadItem[], - destinationPath: string, + destinationPath: string = ROOT_PATH, uploadOptions?: RedundantUploadOptions | FileUploadOptions, requestOptions?: BeeRequestOptions, ): Promise { @@ -333,6 +354,8 @@ export class FileManagerBase implements FileManager { assertUploadableSource(entry); } + assertNotTrashPath(destinationPath); + const destSegments = pathSegments(destinationPath); const destKey = destSegments.join('/'); const { host: destHost } = await this.store.resolveHost(cachedDrive, destinationPath, publisher, requestOptions); @@ -346,6 +369,7 @@ export class FileManagerBase implements FileManager { const plannedFiles: PlannedFile[] = []; const neededFolderPaths = new Set(); + const plannedPaths = new Set(); for (const item of items) { const relSegments = pathSegments(item.path); @@ -354,6 +378,13 @@ export class FileManagerBase implements FileManager { const fullPath = [...destSegments, ...relSegments].join('/'); const parentPath = [...destSegments, ...folderSegments].join('/'); + assertNotTrashPath(fullPath); + + if (plannedPaths.has(fullPath)) { + throw new FileRecordError(`Duplicate destination path in batch: "${fullPath}"`); + } + plannedPaths.add(fullPath); + plannedFiles.push({ item, fullPath, filename, parentPath }); for (let i = 1; i <= folderSegments.length; i++) { @@ -437,6 +468,7 @@ export class FileManagerBase implements FileManager { } const dirtyHosts = new Map(); + const createdFolders: FolderInfo[] = []; for (const { path, parentPath, folderName } of missingFolders) { const parentHost = hostMap.get(parentPath); @@ -455,9 +487,8 @@ export class FileManagerBase implements FileManager { ); hostMap.set(path, folderInfo); + createdFolders.push(folderInfo); dirtyHosts.set(parentHost.topic, parentHost); - - this.emitter.emit(FileManagerEvents.FOLDER_CREATED, { folderInfo }); } const succeeded: FileRecord[] = []; @@ -466,7 +497,7 @@ export class FileManagerBase implements FileManager { await awaitAllPromisesBounded( plannedFiles.map((planned) => async (): Promise => { - // Between-files abort is benign — completed files are valid standalone nodes. + // Stop starting files as soon as the caller aborts requestOptions?.signal?.throwIfAborted(); const parentHost = hostMap.get(planned.parentPath); @@ -474,6 +505,17 @@ export class FileManagerBase implements FileManager { throw new FileRecordError(`Internal error: parent folder not resolved for path: ${planned.fullPath}`); } + const parentMantaray = await this.store.getMantarayNode( + parentHost.topic, + publisher, + parentHost.manifestRef, + requestOptions, + ); + + if (parentMantaray.find(planned.filename)) { + throw new DriveError(`Node already exists at "${planned.fullPath}" — use updateFile to re-version a file`); + } + const { topic, version } = await getTopicAndVersion(this.bee, owner, undefined, undefined, requestOptions); const { contentRefs, rLevel } = await processUpload( @@ -505,17 +547,9 @@ export class FileManagerBase implements FileManager { // In-memory copy is stamped with the already-planned absolute path — no walk needed here. record.path = planned.fullPath; - const parentMantaray = await this.store.getMantarayNode( - parentHost.topic, - publisher, - parentHost.manifestRef, - requestOptions, - ); parentMantaray.addFork(planned.filename, new Reference(record.topic), fileForkMetadata(record)); dirtyHosts.set(parentHost.topic, parentHost); - this.emitter.emit(FileManagerEvents.FILE_UPLOADED, { record }); - return record; }), this.uploadConcurrency, @@ -527,16 +561,38 @@ export class FileManagerBase implements FileManager { }, ); - // Batched saves. Run-to-completion, interrupting them mid-flight would tear state - requestOptions?.signal?.throwIfAborted(); + const mutatedTopics = (): string[] => [...dirtyHosts.keys(), ...createdFolders.map((f) => f.topic)]; + + if (requestOptions?.signal?.aborted) { + this.discardCachedUploads(succeeded, mutatedTopics()); + requestOptions.signal.throwIfAborted(); + } - for (const host of dirtyHosts.values()) { - const mantarayNode = await this.store.getMantarayNode(host.topic, publisher, host.manifestRef, requestOptions); - const updatedNodeRef = await this.store.saveMantarayNode(mantarayNode, host, requestOptions); + // Until every dirty manifest is saved no fork addition is durable, so the batch's records stay + // uncommitted — a partial finalize discards the whole batch rather than caching half a tree. + try { + for (const host of dirtyHosts.values()) { + const mantarayNode = await this.store.getMantarayNode(host.topic, publisher, host.manifestRef, requestOptions); + const updatedNodeRef = await this.store.saveMantarayNode(mantarayNode, host, requestOptions); - if (host.topic === cachedDrive.topic) { - this.driveList[driveIx].manifestRef = updatedNodeRef; + if (host.topic === cachedDrive.topic) { + this.driveList[driveIx].manifestRef = updatedNodeRef; + } } + } catch (err: unknown) { + this.discardCachedUploads(succeeded, mutatedTopics()); + this.errorHandler.handleError(err, 'Failed to finalize upload batch'); + throw err; + } + + for (const record of succeeded) { + this.cacheRecord(record); + } + for (const folderInfo of createdFolders) { + this.emitter.emit(FileManagerEvents.FOLDER_CREATED, { folderInfo }); + } + for (const record of succeeded) { + this.emitter.emit(FileManagerEvents.FILE_UPLOADED, { record }); } const result: UploadFilesResult = { succeeded, failed }; @@ -584,11 +640,18 @@ export class FileManagerBase implements FileManager { if (!fromCache) { cached.path = record.path; } - cached.status = getRecordStatus(cachedDrive, record.topic); + + if (getRecordStatus(cached.path) === NodeStatus.Trashed) { + throw new FileRecordError( + `Cannot update a trashed file: ${cached.trashedFrom ?? cached.path} — recover it first`, + ); + } + cached.status = NodeStatus.Active; const { topic, version } = await getTopicAndVersion(this.bee, owner, cached.version, record.topic, requestOptions); - const { name: filename } = splitPath(cached.path); + const resolvedFork = await this.resolveFileFork(cachedDrive, cached.path, cached.topic, publisher, requestOptions); + const filename = resolvedFork.filename; const mergedMetadata = changes.customMetadata ? { ...cached.customMetadata, ...changes.customMetadata } @@ -630,11 +693,12 @@ export class FileManagerBase implements FileManager { status: cached.status ?? NodeStatus.Active, }; - await this.persistRecord(fr, requestOptions); - await this.syncForkVersion(cachedDrive, driveIx, cached.path, version, publisher, requestOptions); + const writtenVersion = await this.persistRecord(fr, requestOptions); + await this.commitForkVersion(driveIx, resolvedFork, writtenVersion, requestOptions); fr.path = cached.path; + this.cacheRecord(fr); this.emitter.emit(FileManagerEvents.FILE_UPDATED, { record: fr }); return fr; @@ -710,8 +774,15 @@ export class FileManagerBase implements FileManager { throw new FileRecordError(`File feed not found for topic: ${fr.topic.slice(0, 6)}`); } - const versionRecord = await this.store.getRecord(topic.toString(), fr.actPublisher, feedData, requestOptions); + const versionRecord = await this.store.getRecord( + topic.toString(), + fr.actPublisher, + feedData, + { isHeadRead: version === undefined }, + requestOptions, + ); versionRecord.driveId = fr.driveId; + versionRecord.path = localHead?.path ?? fr.path; return versionRecord; } @@ -749,10 +820,19 @@ export class FileManagerBase implements FileManager { const cached = this.recordList.find((f) => f.topic === versionToRestore.topic); + const restoredPath = cached?.path ?? versionToRestore.path; + const resolvedFork = await this.resolveFileFork( + cachedDrive, + restoredPath, + versionToRestore.topic, + publisher, + requestOptions, + ); + const newVersion = feedIndexNext.toString(); const restored: FileRecord = { ...versionToRestore, - path: cached?.path ?? versionToRestore.path, + path: restoredPath, version: newVersion, content: { reference: versionToRestore.content.reference, @@ -761,9 +841,10 @@ export class FileManagerBase implements FileManager { timestamp: Date.now(), }; - await this.persistRecord(restored, requestOptions); - await this.syncForkVersion(cachedDrive, driveIx, restored.path, newVersion, publisher, requestOptions); + const writtenVersion = await this.persistRecord(restored, requestOptions); + await this.commitForkVersion(driveIx, resolvedFork, writtenVersion, requestOptions); + this.cacheRecord(restored); this.emitter.emit(FileManagerEvents.FILE_VERSION_RESTORED, { restored, }); @@ -783,8 +864,10 @@ export class FileManagerBase implements FileManager { const { driveIx, cachedDrive } = this.findDriveOrThrow(driveId); if (!folderName || folderName.includes('/')) { - throw new DriveError(`Invalid folder name ${folderName}`); + throw new FolderError(`Invalid folder name ${folderName}`); } + const actualPath = joinPath(normalizePath(parentPath), folderName); + assertNotTrashPath(actualPath); const { host: parentHost, folder: parentFolder } = await this.store.resolveHost( cachedDrive, @@ -793,6 +876,16 @@ export class FileManagerBase implements FileManager { requestOptions, ); + const existingParentNode = await this.store.getMantarayNode( + parentHost.topic, + publisher, + parentHost.manifestRef, + requestOptions, + ); + if (existingParentNode.find(folderName)) { + throw new FolderError(`Node already exists at "${actualPath}"`); + } + const { folder, node: parentNode } = await this.createFolderNode( cachedDrive, parentHost, @@ -814,7 +907,6 @@ export class FileManagerBase implements FileManager { return folder; } - // Per BFS walk: (1) expand current manifest node, (2) load file feeds found, (3) resolve folder feeds into next node. Each phase is concurrency-bounded. async listFolder( driveId: string | Identifier, path: string, @@ -826,10 +918,35 @@ export class FileManagerBase implements FileManager { const { publisher } = assertReady(this.publisher, this.isInitialized, this.stateFeedTopic); const { cachedDrive } = this.findDriveOrThrow(driveId); + assertNotTrashPath(path); + + if (maxDepth !== undefined && maxDepth <= 0) { + throw new FolderError(`Invalid maxDepth: ${maxDepth}`); + } const { host: startHost } = await this.store.resolveHost(cachedDrive, path, publisher, requestOptions); - const startBasePath = normalizePath(path); + return await this.walkFolder( + cachedDrive, + startHost, + normalizePath(path), + depth, + maxDepth, + publisher, + requestOptions, + ); + } + + // Per BFS walk: (1) expand current manifest node, (2) load file feeds found, (3) resolve folder feeds into next node. Each phase is concurrency-bounded. + private async walkFolder( + cachedDrive: DriveInfo, + startHost: ManifestHost, + startBasePath: string, + depth: ListDepth, + maxDepth: number | undefined, + publisher: string, + requestOptions?: BeeRequestOptions, + ): Promise { const results: NodeEntry[] = []; let visitedNodes: { host: ManifestHost; basePath: string }[] = [{ host: startHost, basePath: startBasePath }]; let currentDepth = 0; @@ -848,13 +965,15 @@ export class FileManagerBase implements FileManager { requestOptions, ); - return getAllNodeEntries(mantarayNode).map((e) => ({ ...e, path: joinPath(item.basePath, e.path) })); + return getAllNodeEntries(mantarayNode) + .map((e) => ({ ...e, path: joinPath(item.basePath, e.path) })) + .filter((e) => e.path !== TRASH_FOLDER_NAME); }), this.feedFetchConcurrency, (entries) => headers.push(...entries), (reason) => { if (requestOptions?.signal?.aborted) return; - this.logger.error(`listFolder: failed to expand manifest: ${reason}`); + this.logger.error(`walkFolder: failed to expand manifest: ${reason}`); }, ); @@ -868,7 +987,7 @@ export class FileManagerBase implements FileManager { const { record } = await this.loadRecord(e.topic, owner, actPublisher, version, requestOptions); record.path = e.path; record.driveId = cachedDrive.id; - record.status = getRecordStatus(cachedDrive, e.topic); + record.status = getRecordStatus(e.path); return record; }), this.feedFetchConcurrency, @@ -878,7 +997,7 @@ export class FileManagerBase implements FileManager { }, (reason, ix) => { if (requestOptions?.signal?.aborted) return; - this.logger.error(`listFolder: failed to load file ${fileHeaders[ix].topic}: ${reason}`); + this.logger.error(`walkFolder: failed to load file ${fileHeaders[ix].topic}: ${reason}`); }, ); @@ -897,13 +1016,13 @@ export class FileManagerBase implements FileManager { ); if (feedIndex.equals(FeedIndex.MINUS_ONE)) { - this.logger.warn(`listFolder: folder feed not found for ${e.path} — skipping`); + this.logger.warn(`walkFolder: folder feed not found for ${e.path} — skipping`); return null; } const manifestRef: ActReferences = payload.toJSON() as ActReferences; assertActReferences(manifestRef); - this.store.setNodeFeedIndex(e.topic, feedIndexNext.toBigInt()); + this.store.setNodeNextIndexCache(e.topic, feedIndexNext.toBigInt()); return { type: NodeType.Folder, @@ -915,21 +1034,19 @@ export class FileManagerBase implements FileManager { actPublisher: e.actPublisher ?? publisher, path: e.path, driveId: cachedDrive.id, - status: getRecordStatus(cachedDrive, e.topic), + status: getRecordStatus(e.path), }; }), this.feedFetchConcurrency, (folder) => { if (folder) { results.push(folder); - if (folder.status !== NodeStatus.Trashed) { - nextFrontier.push({ host: folder, basePath: folder.path }); - } + nextFrontier.push({ host: folder, basePath: folder.path }); } }, (reason, ix) => { if (requestOptions?.signal?.aborted) return; - this.logger.error(`listFolder: failed to resolve folder ${folderHeaders[ix].path}: ${reason}`); + this.logger.error(`walkFolder: failed to resolve folder ${folderHeaders[ix].path}: ${reason}`); }, ); @@ -947,7 +1064,7 @@ export class FileManagerBase implements FileManager { // via listFolder), then fetches them. path '/' the whole drive. async downloadFolder( driveId: string | Identifier, - path: string, + path: string = ROOT_PATH, options?: DownloadOptions, requestOptions?: BeeRequestOptions, ): Promise { @@ -958,7 +1075,9 @@ export class FileManagerBase implements FileManager { const normalized = normalizePath(path); const prefix = normalized ? normalized + '/' : ''; const driveIdStr = new Identifier(driveId).toString(); - const files = this.recordList.filter((f) => f.driveId === driveIdStr && f.path.startsWith(prefix)); + const files = this.recordList.filter( + (f) => f.driveId === driveIdStr && f.path.startsWith(prefix) && !isTrashPath(f.path), + ); return this.downloadFiles(files, options, requestOptions); } @@ -967,37 +1086,24 @@ export class FileManagerBase implements FileManager { fromPath: string, toPath: string, sourceDriveId: string | Identifier, - targetDriveId?: string | Identifier, requestOptions?: BeeRequestOptions, ): Promise { requestOptions?.signal?.throwIfAborted(); const { publisher } = assertReady(this.publisher, this.isInitialized, this.stateFeedTopic); - const sourceDriveIdStr = new Identifier(sourceDriveId).toString(); - const { driveIx: sourceDriveIx, cachedDrive: cachedSource } = this.findDriveOrThrow(sourceDriveIdStr); + const { driveIx: sourceDriveIx, cachedDrive: cachedSource } = this.findDriveOrThrow(sourceDriveId); // disable drive move if (!fromPath || fromPath === ROOT_PATH) { - throw new DriveError('Cannot move root folder'); + throw new FolderError('Cannot move root folder'); } if (!toPath || toPath === ROOT_PATH) { - throw new DriveError('Invalid destination path'); + throw new FolderError('Invalid destination path'); } + assertNotTrashPath(fromPath); + assertNotTrashPath(toPath); - const targetDriveIdStr = targetDriveId ? new Identifier(targetDriveId).toString() : undefined; - - const isCrossDrive = !!targetDriveIdStr && targetDriveIdStr !== sourceDriveIdStr; - const effectiveTargetId = targetDriveIdStr ?? sourceDriveIdStr; - - let cachedTargetDrive: DriveInfo = cachedSource; - let cachedTargetDriveIx = sourceDriveIx; - if (targetDriveIdStr) { - const { driveIx, cachedDrive } = this.findDriveOrThrow(targetDriveIdStr); - cachedTargetDrive = cachedDrive; - cachedTargetDriveIx = driveIx; - } - - if (!isCrossDrive && fromPath === toPath) { - throw new DriveError('Source and destination paths are identical'); + if (fromPath === toPath) { + throw new FolderError('Source and destination paths are identical'); } const { parentPath: srcParentPath, name: srcName } = splitPath(fromPath); @@ -1011,19 +1117,14 @@ export class FileManagerBase implements FileManager { const sourceFork = sourceNode.find(srcName); if (!sourceFork) { - throw new DriveError(`Path not found: ${fromPath}`); + throw new FolderError(`Path not found: ${fromPath}`); } const forkMetadata = sourceFork.metadata ?? {}; const isFile = forkMetadata[MANIFEST_METADATA_NODE_TYPE] === NodeType.File; - const movedTopic = forkMetadata[MANIFEST_METADATA_NODE_TOPIC]; - if (movedTopic && getRecordStatus(cachedSource, movedTopic) === NodeStatus.Trashed) { - throw new FileRecordError('Cannot move a trashed file/folder; recover it first'); - } - const { host: tgtParentHost, folder: tgtParentFolder } = await this.store.resolveHost( - cachedTargetDrive, + cachedSource, tgtParentPath, publisher, requestOptions, @@ -1036,84 +1137,85 @@ export class FileManagerBase implements FileManager { const existing = targetMantaray.find(tgtName); if (existing) { - throw new DriveError(`Destination already exists: ${toPath}`); + throw new FolderError(`Destination already exists: ${toPath}`); } + let moved: { record: FileRecord; version: string } | undefined; if (isFile) { const fileTopic = forkMetadata[MANIFEST_METADATA_FILE_TOPIC]; if (!fileTopic) { throw new FileRecordError(`Fork at ${fromPath} has no file topic — cannot move`); } - const { record } = await this.loadRecord(fileTopic, this.signerAddress, publisher, undefined, requestOptions); - - record.path = tgtName; - record.driveId = effectiveTargetId; + const forkVersion = forkMetadata[MANIFEST_METADATA_NODE_VERSION]; + const { record } = await this.loadRecord( + fileTopic, + this.signerAddress, + publisher, + forkVersion !== undefined ? new FeedIndex(forkVersion).toBigInt() : undefined, + requestOptions, + ); - const newVersion = record.version !== undefined ? new FeedIndex(record.version) : FEED_INDEX_ZERO; - record.version = newVersion.next().toString(); + const head = record.version !== undefined ? new FeedIndex(record.version) : undefined; - await this.persistRecord(record, requestOptions); + const persistable: FileRecord = { ...record, path: tgtName, version: head?.next().toString() }; + const writtenVersion = await this.persistRecord(persistable, requestOptions); - record.path = toPath; - forkMetadata[MANIFEST_METADATA_NODE_VERSION] = record.version; + forkMetadata[MANIFEST_METADATA_NODE_VERSION] = writtenVersion; + moved = { record, version: writtenVersion }; } - sourceNode.removeFork(srcName); if (sameParent) { + sourceNode.removeFork(srcName); sourceNode.addFork(tgtName, sourceFork.targetAddress, forkMetadata); - } else { - targetMantaray.addFork(tgtName, sourceFork.targetAddress, forkMetadata); - } - const newSrcManifestRef = await this.store.saveMantarayNode(sourceNode, srcParentHost, requestOptions); - if (!srcParentFolder) { - this.driveList[sourceDriveIx].manifestRef = newSrcManifestRef; - } + const newSrcManifestRef = await this.store.saveMantarayNode(sourceNode, srcParentHost, requestOptions); - if (!sameParent) { + if (!srcParentFolder) { + this.driveList[sourceDriveIx].manifestRef = newSrcManifestRef; + } + } else { + targetMantaray.addFork(tgtName, sourceFork.targetAddress, forkMetadata); const newTgtManifestRef = await this.store.saveMantarayNode(targetMantaray, tgtParentHost, requestOptions); if (!tgtParentFolder) { - this.driveList[cachedTargetDriveIx].manifestRef = newTgtManifestRef; + this.driveList[sourceDriveIx].manifestRef = newTgtManifestRef; + } + + sourceNode.removeFork(srcName); + const newSrcManifestRef = await this.store.saveMantarayNode(sourceNode, srcParentHost, requestOptions); + + if (!srcParentFolder) { + this.driveList[sourceDriveIx].manifestRef = newSrcManifestRef; } } if (!isFile) { - // reset the in-memory cache - const fromPrefix = fromPath + '/'; - const toPrefix = toPath + '/'; - for (const f of this.recordList) { - if (f.driveId === sourceDriveIdStr && f.path.startsWith(fromPrefix)) { - f.path = toPrefix + f.path.substring(fromPrefix.length); - if (isCrossDrive) { - f.driveId = effectiveTargetId; - } - } - } + this.rewriteRecordPaths(cachedSource.id, fromPath, toPath); + this.emitter.emit(FileManagerEvents.FOLDER_MOVED, { + driveId: cachedSource.id, + fromPath, + toPath, + folderInfo: folderInfoFromMetadata(forkMetadata, cachedSource, toPath, { + owner: this.signerAddress, + actPublisher: publisher, + }), + }); - const sourceTrash = cachedSource.trashedNodes ?? []; - const affected = sourceTrash.filter((n) => n.path.startsWith(fromPrefix)); - - if (affected.length > 0) { - const rewrite = (n: TrashEntry): TrashEntry => ({ - ...n, - path: toPrefix + n.path.substring(fromPrefix.length), - }); - - if (isCrossDrive) { - cachedSource.trashedNodes = sourceTrash.filter((n) => !n.path.startsWith(fromPrefix)); - cachedTargetDrive.trashedNodes = [...(cachedTargetDrive.trashedNodes ?? []), ...affected.map(rewrite)]; - await this.persistAdminDriveFork(sourceDriveIx, requestOptions); - await this.persistAdminDriveFork(cachedTargetDriveIx, requestOptions); - } else { - cachedSource.trashedNodes = sourceTrash.map((n) => (n.path.startsWith(fromPrefix) ? rewrite(n) : n)); - await this.persistAdminDriveFork(sourceDriveIx, requestOptions); - } - } + return; } - this.emitter.emit(FileManagerEvents.FILE_MOVED, { fromPath, toPath }); + if (moved) { + moved.record.path = toPath; + moved.record.version = moved.version; + } + + this.emitter.emit(FileManagerEvents.FILE_MOVED, { + driveId: cachedSource.id, + fromPath, + toPath, + record: moved?.record, + }); } async forget(driveId: string | Identifier, path: string, requestOptions?: BeeRequestOptions): Promise { @@ -1122,7 +1224,10 @@ export class FileManagerBase implements FileManager { const { driveIx, cachedDrive } = this.findDriveOrThrow(driveId); if (!path || path === ROOT_PATH) { - throw new DriveError('Cannot forget drive root'); + throw new FolderError('Cannot forget drive root'); + } + if (normalizePath(path) === TRASH_FOLDER_NAME) { + throw new FolderError(`Cannot forget "${TRASH_FOLDER_NAME}" — use emptyTrash`); } const { parentPath, name } = splitPath(path); @@ -1166,8 +1271,14 @@ export class FileManagerBase implements FileManager { } } - await this.pruneTrashOverlay(driveIx, (n) => n.topic === nodeTopic || n.path.startsWith(prefix), requestOptions); - this.emitter.emit(FileManagerEvents.FOLDER_FORGOTTEN, { driveInfo: cachedDrive, path }); + this.emitter.emit(FileManagerEvents.FOLDER_FORGOTTEN, { + driveId: cachedDrive.id, + path, + folderInfo: folderInfoFromMetadata(meta, cachedDrive, path, { + owner: this.signerAddress, + actPublisher: publisher, + }), + }); return; } @@ -1178,113 +1289,246 @@ export class FileManagerBase implements FileManager { if (fiIndex !== -1) { this._recordList.splice(fiIndex, 1); } - await this.pruneTrashOverlay(driveIx, (n) => n.topic === nodeTopic || n.path === path, requestOptions); - this.emitter.emit(FileManagerEvents.FILE_FORGOTTEN, { record: forgotten, path }); + this.emitter.emit(FileManagerEvents.FILE_FORGOTTEN, { driveId: cachedDrive.id, path, record: forgotten }); } // --- Trash operations --- - async trashFile(record: FileRecord, requestOptions?: BeeRequestOptions): Promise { - await this.setTrashState( - record.driveId, - { topic: record.topic, type: NodeType.File, path: record.path, version: record.version }, - true, - requestOptions, - ); - record.status = NodeStatus.Trashed; - this.emitter.emit(FileManagerEvents.FILE_TRASHED, { record }); - } + async trash(driveId: string | Identifier, path: string, requestOptions?: BeeRequestOptions): Promise { + requestOptions?.signal?.throwIfAborted(); + const { publisher } = assertReady(this.publisher, this.isInitialized, this.stateFeedTopic); + const { driveIx, cachedDrive } = this.findDriveOrThrow(driveId); - async recoverFile(record: FileRecord, requestOptions?: BeeRequestOptions): Promise { - await this.setTrashState( - record.driveId, - { topic: record.topic, type: NodeType.File, path: record.path }, - false, - requestOptions, - ); - record.status = NodeStatus.Active; - this.emitter.emit(FileManagerEvents.FILE_RECOVERED, { record }); - } + if (!path || path === ROOT_PATH) { + throw new FolderError('Cannot trash drive root'); + } + assertNotTrashPath(path); - async trashFolder(folder: FolderInfo, requestOptions?: BeeRequestOptions): Promise { - await this.setTrashState( - folder.driveId, - { topic: folder.topic, type: NodeType.Folder, path: folder.path }, - true, - requestOptions, - ); - folder.status = NodeStatus.Trashed; - this.emitter.emit(FileManagerEvents.FOLDER_TRASHED, { folder }); + const sourcePath = normalizePath(path); + const source = await this.resolveNodeFork(cachedDrive, sourcePath, publisher, requestOptions); + const topic = source.metadata[MANIFEST_METADATA_NODE_TOPIC]; + const type = source.metadata[MANIFEST_METADATA_NODE_TYPE] as NodeType | undefined; + if (!topic || !type) { + throw new FileRecordError(`Fork at ${sourcePath} is missing node metadata — cannot trash`); + } + + const trash = await this.ensureTrashHost(driveIx, cachedDrive, publisher, requestOptions); + const trashedPath = trashPathOf(topic); + + const trashedMetadata = { ...source.metadata, [MANIFEST_METADATA_TRASHED_FROM]: sourcePath }; + + source.node.removeFork(source.filename); + trash.node.addFork(topic, source.targetAddress, trashedMetadata); + + await this.store.saveMantarayNode(trash.node, trash.host, requestOptions); + const newSourceRef = await this.store.saveMantarayNode(source.node, source.host, requestOptions); + if (!source.folder) { + this.driveList[driveIx].manifestRef = newSourceRef; + } + + if (type === NodeType.Folder) { + this.rewriteRecordPaths(cachedDrive.id, sourcePath, trashedPath); + this.emitter.emit(FileManagerEvents.FOLDER_TRASHED, { + driveId: cachedDrive.id, + path: sourcePath, + trashedPath, + folderInfo: folderInfoFromMetadata(trashedMetadata, cachedDrive, trashedPath, { + owner: this.signerAddress, + actPublisher: publisher, + }), + }); + + return; + } + + const record = this.recordList.find((f) => f.topic === topic); + if (record) { + record.path = trashedPath; + record.trashedFrom = sourcePath; + record.status = NodeStatus.Trashed; + } + + this.emitter.emit(FileManagerEvents.FILE_TRASHED, { + driveId: cachedDrive.id, + path: sourcePath, + trashedPath, + record, + }); } - async recoverFolder(folder: FolderInfo, requestOptions?: BeeRequestOptions): Promise { - await this.setTrashState( - folder.driveId, - { topic: folder.topic, type: NodeType.Folder, path: folder.path }, - false, - requestOptions, - ); - folder.status = NodeStatus.Active; - this.emitter.emit(FileManagerEvents.FOLDER_RECOVERED, { folder }); + async recover( + driveId: string | Identifier, + trashedPath: string, + toPath?: string, + requestOptions?: BeeRequestOptions, + ): Promise { + requestOptions?.signal?.throwIfAborted(); + const { publisher } = assertReady(this.publisher, this.isInitialized, this.stateFeedTopic); + const { driveIx, cachedDrive } = this.findDriveOrThrow(driveId); + + const segments = pathSegments(trashedPath); + if (segments.length !== 2 || segments[0] !== TRASH_FOLDER_NAME) { + throw new FileRecordError(`Not a trashed node path: "${trashedPath}" — expected "${TRASH_FOLDER_NAME}/"`); + } + const topic = segments[1]; + const normalizedTrashedPath = trashPathOf(topic); + + const trash = await this.resolveTrashHost(cachedDrive, publisher, requestOptions); + const trashedFork = trash?.node.find(topic); + if (!trash || !trashedFork) { + throw new FileRecordError(`Not trashed, cannot recover: ${trashedPath}`); + } + + const metadata = { ...(trashedFork.metadata ?? {}) }; + const trashedFrom = metadata[MANIFEST_METADATA_TRASHED_FROM]; + const destination = toPath ?? trashedFrom; + if (!destination) { + throw new FileRecordError(`No recorded origin for ${trashedPath} — pass an explicit destination`); + } + assertValidNodePath(destination); + assertNotTrashPath(destination); + delete metadata[MANIFEST_METADATA_TRASHED_FROM]; + + const { parentPath, name } = splitPath(destination); + const { + host: destHost, + folder: destFolder, + node: destNode, + } = await this.store.resolveHostMantaray(cachedDrive, parentPath, publisher, requestOptions); + + if (destNode.find(name)) { + throw new DriveError(`Destination already exists: ${destination}`); + } + + destNode.addFork(name, trashedFork.targetAddress, metadata); + const newDestRef = await this.store.saveMantarayNode(destNode, destHost, requestOptions); + + if (!destFolder) { + this.driveList[driveIx].manifestRef = newDestRef; + } + + trash.node.removeFork(topic); + await this.store.saveMantarayNode(trash.node, trash.host, requestOptions); + + const restoredPath = normalizePath(destination); + if (metadata[MANIFEST_METADATA_NODE_TYPE] === NodeType.Folder) { + this.rewriteRecordPaths(cachedDrive.id, normalizedTrashedPath, restoredPath); + this.emitter.emit(FileManagerEvents.FOLDER_RECOVERED, { + driveId: cachedDrive.id, + trashedPath: normalizedTrashedPath, + restoredPath, + folderInfo: folderInfoFromMetadata(metadata, cachedDrive, restoredPath, { + owner: this.signerAddress, + actPublisher: publisher, + }), + }); + + return restoredPath; + } + + const record = this.recordList.find((f) => f.topic === topic); + if (record) { + record.path = restoredPath; + delete record.trashedFrom; + record.status = NodeStatus.Active; + } + + this.emitter.emit(FileManagerEvents.FILE_RECOVERED, { + driveId: cachedDrive.id, + trashedPath: normalizedTrashedPath, + restoredPath, + record, + }); + + return restoredPath; } - async listTrash(driveId: string | Identifier, requestOptions?: BeeRequestOptions): Promise { + async listTrash( + driveId: string | Identifier, + depth: ListDepth = ListDepth.Shallow, + maxDepth?: number, + requestOptions?: BeeRequestOptions, + ): Promise { requestOptions?.signal?.throwIfAborted(); const { publisher } = assertReady(this.publisher, this.isInitialized, this.stateFeedTopic); const { cachedDrive } = this.findDriveOrThrow(driveId); - const entries = cachedDrive.trashedNodes ?? []; - const owner = this.signerAddress; - const trashedResults: NodeEntry[] = []; + if (maxDepth !== undefined && maxDepth <= 0) { + throw new FolderError(`Invalid maxDepth: ${maxDepth}`); + } - await awaitAllPromisesBounded( - entries.map((entry) => async (): Promise => { - const version = entry.version ? new FeedIndex(entry.version).toBigInt() : undefined; + const trash = await this.resolveTrashHost(cachedDrive, publisher, requestOptions); + if (!trash) { + return []; + } - const feedData = await getFeedData(this.bee, new Topic(entry.topic), owner, version, requestOptions); + const entries = await this.walkFolder( + cachedDrive, + trash.host, + TRASH_FOLDER_NAME, + depth, + maxDepth, + publisher, + requestOptions, + ); - if (feedData.feedIndex.equals(FeedIndex.MINUS_ONE)) { - this.logger.warn(`listTrash: feed not found for ${entry.path} — skipping`); - return null; - } + const originByTopic = new Map(); + for (const header of getAllNodeEntries(trash.node)) { + const from = header.rawMetadata[MANIFEST_METADATA_TRASHED_FROM]; + if (from) { + originByTopic.set(header.topic, from); + } + } - if (entry.type === NodeType.File) { - const fr = await this.store.getRecord(entry.topic, publisher, feedData, requestOptions); - fr.path = entry.path; - fr.status = NodeStatus.Trashed; - fr.driveId = cachedDrive.id; + for (const entry of entries) { + const segments = pathSegments(entry.path); + const origin = originByTopic.get(segments[1]); + if (origin) { + entry.trashedFrom = segments.length > 2 ? joinPath(origin, segments.slice(2).join('/')) : origin; + } + } - return fr; - } + return entries; + } - const manifestRef = feedData.payload.toJSON() as ActReferences; - assertActReferences(manifestRef); + async emptyTrash(driveId: string | Identifier, requestOptions?: BeeRequestOptions): Promise { + requestOptions?.signal?.throwIfAborted(); + const { publisher } = assertReady(this.publisher, this.isInitialized, this.stateFeedTopic); + const { cachedDrive } = this.findDriveOrThrow(driveId); - return { - type: NodeType.Folder, - owner, - topic: entry.topic, - manifestRef, - batchId: cachedDrive.batchId, - redundancyLevel: cachedDrive.redundancyLevel, - actPublisher: publisher, - path: entry.path, - driveId: cachedDrive.id, - status: NodeStatus.Trashed, - }; - }), - this.feedFetchConcurrency, - (node) => { - if (node) trashedResults.push(node); - }, - (reason, ix) => { - if (requestOptions?.signal?.aborted) return; - this.logger.error(`listTrash: failed to resolve ${entries[ix].path}: ${reason}`); - }, - ); + const trash = await this.resolveTrashHost(cachedDrive, publisher, requestOptions); + if (!trash) { + return 0; + } + + const topics = getAllNodeEntries(trash.node) + .filter((e) => pathSegments(e.path).length === 1) + .map((e) => e.topic); + + if (topics.length === 0) { + return 0; + } + + for (const topic of topics) { + trash.node.removeFork(topic); + } + await this.store.saveMantarayNode(trash.node, trash.host, requestOptions); + + const trashPrefix = TRASH_FOLDER_NAME + '/'; + for (let i = this.recordList.length - 1; i >= 0; --i) { + const record = this.recordList[i]; + if (record.driveId === cachedDrive.id && record.path.startsWith(trashPrefix)) { + this._recordList.splice(i, 1); + } + } + for (const topic of topics) { + this.store.evict(topic); + } + + this.emitter.emit(FileManagerEvents.TRASH_EMPTIED, { driveId: cachedDrive.id, count: topics.length }); - return trashedResults; + return topics.length; } // --- Private helpers --- @@ -1293,6 +1537,27 @@ export class FileManagerBase implements FileManager { this.publisher = (await this.bee.getNodeAddresses(requestOptions)).publicKey; } + private resetState(): void { + this._isInitialized = false; + this.publisher = undefined; + this.stateFeedTopic = undefined; + this._adminStamp = undefined; + this.adminRedundancyLevel = RedundancyLevel.OFF; + this._driveList.length = 0; + this._recordList.length = 0; + this.store.clear(); + } + + private discardCachedUploads(records: FileRecord[], mutatedTopics: string[]): void { + for (const record of records) { + this.store.evict(record.topic); + } + + for (const topic of mutatedTopics) { + this.store.evict(topic); + } + } + private async tryToFetchAdminState(requestOptions?: BeeRequestOptions): Promise { if (!this.publisher) { throw new SignerError('Publisher not found'); @@ -1369,11 +1634,10 @@ export class FileManagerBase implements FileManager { topic: new Topic(generateRandomBytes(Topic.LENGTH)).toString(), isAdmin, actPublisher: publisher, - trashedNodes: [], }; const driveNode = new MantarayNode(); - this.store.setNodeFeedIndex(newDrive.topic, 0n); + this.store.setNodeNextIndexCache(newDrive.topic, 0n); newDrive.manifestRef = await this.store.saveMantarayNode(driveNode, newDrive, requestOptions); this.store.setManifestCache(newDrive.topic, driveNode); @@ -1409,11 +1673,6 @@ export class FileManagerBase implements FileManager { throw new DriveError('Admin state already exists. Pass reset=true to overwrite.'); } - if (reset) { - this._driveList.length = 0; - this.store.clear(); - } - const randomTopic = generateRandomBytes(Topic.LENGTH); const newStateFeedTopic = new Topic(randomTopic); const topicUploadRes = await this.bee.uploadData( @@ -1431,10 +1690,16 @@ export class FileManagerBase implements FileManager { const statefw = this.bee.makeFeedWriter(FILEMANAGER_STATE_TOPIC.toUint8Array(), this.signer, requestOptions); await statefw.uploadPayload(batchId, JSON.stringify(topicState), { index: feedIndexNext }); + if (reset) { + this._recordList.length = 0; + this._driveList.length = 0; + this.store.clear(); + } + this.stateFeedTopic = newStateFeedTopic; this.adminRedundancyLevel = redundancyLevel; this.store.setManifestCache(newStateFeedTopic.toString(), new MantarayNode()); - this.store.setNodeFeedIndex(newStateFeedTopic.toString(), 0n); + this.store.setNodeNextIndexCache(newStateFeedTopic.toString(), 0n); } private async initDriveList(requestOptions?: BeeRequestOptions): Promise { @@ -1473,7 +1738,7 @@ export class FileManagerBase implements FileManager { const entries = getAllNodeEntries(adminMantaray).filter((e) => e.type === NodeType.Drive); - this.store.setNodeFeedIndex(this.stateFeedTopic.toString(), feedIndexNext.toBigInt()); + this.store.setNodeNextIndexCache(this.stateFeedTopic.toString(), feedIndexNext.toBigInt()); await settlePromises( entries.map(async (entry) => { @@ -1501,7 +1766,7 @@ export class FileManagerBase implements FileManager { try { verifyStampUsability(this.adminStamp, driveInfo.batchId, false); } catch (err: unknown) { - this.errorHandler.handleError(err); + this.errorHandler.handleError(err, 'Amdin stamp verification failed'); this.emitter.emit(FileManagerEvents.STATE_INVALID, true); throw err; } @@ -1509,7 +1774,7 @@ export class FileManagerBase implements FileManager { this.adminRedundancyLevel = driveInfo.redundancyLevel; } - this.store.setNodeFeedIndex(driveInfo.topic, driveFeedIndexNext.toBigInt()); + this.store.setNodeNextIndexCache(driveInfo.topic, driveFeedIndexNext.toBigInt()); return driveInfo; }), @@ -1543,7 +1808,13 @@ export class FileManagerBase implements FileManager { } private findDriveOrThrow(driveId: string | Identifier): { driveIx: number; cachedDrive: DriveInfo } { - const driveIdStr = new Identifier(driveId).toString(); + let driveIdStr: string; + try { + driveIdStr = new Identifier(driveId).toString(); + } catch (err: unknown) { + this.errorHandler.handleError(err, `Invalid driveId: ${driveId}`); + throw new DriveError(`Invalid driveId: ${driveId}`); + } const driveIx = this.driveList.findIndex((d) => d.id === driveIdStr); if (driveIx === -1) { @@ -1611,7 +1882,7 @@ export class FileManagerBase implements FileManager { }; const folderNode = new MantarayNode(); - this.store.setNodeFeedIndex(newFolderTopic, 0n); + this.store.setNodeNextIndexCache(newFolderTopic, 0n); fi.manifestRef = await this.store.saveMantarayNode(folderNode, fi, requestOptions); this.store.setManifestCache(newFolderTopic, folderNode); @@ -1626,14 +1897,12 @@ export class FileManagerBase implements FileManager { return { folder: fi, node: parentNode }; } - private async syncForkVersion( + private async resolveNodeFork( drive: DriveInfo, - driveIx: number, absolutePath: string, - newVersion: string, publisher: string, requestOptions?: BeeRequestOptions, - ): Promise { + ): Promise { const { parentPath, name: filename } = splitPath(absolutePath); const { @@ -1641,18 +1910,107 @@ export class FileManagerBase implements FileManager { folder: parentFolder, node: parentNode, } = await this.store.resolveHostMantaray(drive, parentPath, publisher, requestOptions); - const fileFork = parentNode.find(filename); - if (!fileFork) { - throw new DriveError(`Path not found: ${absolutePath}`); + const fork = parentNode.find(filename); + if (!fork) { + throw new FolderError(`Path not found: ${absolutePath}`); } - const forkMetadata = { ...(fileFork.metadata ?? {}) }; - forkMetadata[MANIFEST_METADATA_NODE_VERSION] = newVersion; - parentNode.removeFork(filename); - parentNode.addFork(filename, fileFork.targetAddress, forkMetadata); + return { + host: parentHost, + folder: parentFolder, + node: parentNode, + filename, + targetAddress: fork.targetAddress, + metadata: { ...(fork.metadata ?? {}) }, + }; + } - const newManifestRef = await this.store.saveMantarayNode(parentNode, parentHost, requestOptions); - if (!parentFolder) { + private async resolveFileFork( + drive: DriveInfo, + absolutePath: string, + expectedTopic: string, + publisher: string, + requestOptions?: BeeRequestOptions, + ): Promise { + const fork = await this.resolveNodeFork(drive, absolutePath, publisher, requestOptions); + + if (fork.metadata[MANIFEST_METADATA_NODE_TOPIC] !== expectedTopic) { + throw new FileRecordError( + `Fork at ${absolutePath} belongs to a different node than ${expectedTopic.slice(0, 6)} — refusing to write its version`, + ); + } + + return fork; + } + + private async resolveTrashHost( + drive: DriveInfo, + publisher: string, + requestOptions?: BeeRequestOptions, + ): Promise<{ host: ManifestHost; node: MantarayNode } | null> { + const rootNode = await this.store.getMantarayNode(drive.topic, publisher, drive.manifestRef, requestOptions); + if (!rootNode.find(TRASH_FOLDER_NAME)) { + return null; + } + + const { host, node } = await this.store.resolveHostMantaray(drive, TRASH_FOLDER_NAME, publisher, requestOptions); + + return { host, node }; + } + + private async ensureTrashHost( + driveIx: number, + drive: DriveInfo, + publisher: string, + requestOptions?: BeeRequestOptions, + ): Promise<{ host: ManifestHost; node: MantarayNode }> { + const existing = await this.resolveTrashHost(drive, publisher, requestOptions); + if (existing) { + return existing; + } + + const { host: rootHost } = await this.store.resolveHost(drive, ROOT_PATH, publisher, requestOptions); + const { folder, node: rootNode } = await this.createFolderNode( + drive, + rootHost, + ROOT_PATH, + TRASH_FOLDER_NAME, + publisher, + undefined, + requestOptions, + ); + + this.driveList[driveIx].manifestRef = await this.store.saveMantarayNode(rootNode, rootHost, requestOptions); + const node = await this.store.getMantarayNode(folder.topic, publisher, folder.manifestRef, requestOptions); + + return { host: folder, node }; + } + + private rewriteRecordPaths(driveId: string, fromPath: string, toPath: string): void { + const fromPrefix = normalizePath(fromPath) + '/'; + const toPrefix = normalizePath(toPath) + '/'; + + for (const record of this.recordList) { + if (record.driveId === driveId && record.path.startsWith(fromPrefix)) { + record.path = toPrefix + record.path.substring(fromPrefix.length); + record.status = getRecordStatus(record.path); + } + } + } + + // Re-stamps a resolved fork's cached version so it tracks the file's new feed head + private async commitForkVersion( + driveIx: number, + fork: ResolvedFileFork, + newVersion: string, + requestOptions?: BeeRequestOptions, + ): Promise { + const forkMetadata = { ...fork.metadata, [MANIFEST_METADATA_NODE_VERSION]: newVersion }; + fork.node.removeFork(fork.filename); + fork.node.addFork(fork.filename, fork.targetAddress, forkMetadata); + + const newManifestRef = await this.store.saveMantarayNode(fork.node, fork.host, requestOptions); + if (!fork.folder) { this.driveList[driveIx].manifestRef = newManifestRef; } } @@ -1664,8 +2022,10 @@ export class FileManagerBase implements FileManager { version?: bigint, requestOptions?: BeeRequestOptions, ): Promise<{ record: FileRecord; fromCache: boolean }> { - const cached = this.recordList.find((f) => f.topic === topic); - if (cached) { + const cachedIx = this.recordList.findIndex((f) => f.topic === topic); + const cached = cachedIx === -1 ? undefined : this.recordList[cachedIx]; + + if (cached && (version === undefined || cached.version === FeedIndex.fromBigInt(version).toString())) { return { record: cached, fromCache: true }; } @@ -1674,20 +2034,31 @@ export class FileManagerBase implements FileManager { throw new FileRecordError(`File record not found for topic: ${topic.slice(0, 6)}`); } - const loaded = await this.store.getRecord(topic, actPublisher, feedData, requestOptions); - this._recordList.push(loaded); + const loaded = await this.store.getRecord(topic, actPublisher, feedData, { isHeadRead: true }, requestOptions); + if (cachedIx === -1) { + this._recordList.push(loaded); + } else { + this._recordList[cachedIx] = loaded; + } return { record: loaded, fromCache: false }; } - private async persistRecord(fr: FileRecord, requestOptions?: BeeRequestOptions): Promise { + private async persistRecord(fr: FileRecord, requestOptions?: BeeRequestOptions): Promise { + let index: bigint; try { - await this.store.saveRecord(fr, requestOptions); + ({ index } = await this.store.saveRecord(fr, requestOptions)); } catch (err: unknown) { this.errorHandler.handleError(err, `Failed to save record: ${fr.path}`); throw new FileRecordError(`Failed to save record`, err); } + fr.version = FeedIndex.fromBigInt(index).toString(); + + return fr.version; + } + + private cacheRecord(fr: FileRecord): void { const existingIx = this.recordList.findIndex((f) => f.topic === fr.topic); if (existingIx !== -1) { this._recordList[existingIx] = fr; @@ -1696,67 +2067,6 @@ export class FileManagerBase implements FileManager { } } - private async setTrashState( - driveId: string | undefined, - entry: TrashEntry, - isTrashed: boolean, - requestOptions?: BeeRequestOptions, - ): Promise { - if (!driveId) { - throw new FileRecordError(`Drive ID missing for: ${entry.path}`); - } - - const { driveIx, cachedDrive } = this.findDriveOrThrow(driveId); - const isAlreadyTrashed = getRecordStatus(cachedDrive, entry.topic) === NodeStatus.Trashed; - - if (isTrashed && isAlreadyTrashed) { - throw new FileRecordError(`Already trashed: ${entry.path}`); - } - if (!isTrashed && !isAlreadyTrashed) { - throw new FileRecordError(`Not trashed, cannot recover: ${entry.path}`); - } - - const current = cachedDrive.trashedNodes ?? []; - const withoutEntry = current.filter((n) => n.topic !== entry.topic); - cachedDrive.trashedNodes = isTrashed ? [...withoutEntry, entry] : withoutEntry; - await this.persistAdminDriveFork(driveIx, requestOptions); - - return cachedDrive; - } - - private async persistAdminDriveFork(driveIx: number, requestOptions?: BeeRequestOptions): Promise { - const { publisher, stateFeedTopic } = assertReady(this.publisher, this.isInitialized, this.stateFeedTopic); - - const adminMantaray = this.store.getManifestCache(stateFeedTopic); - if (!adminMantaray) { - throw new DriveError('Admin manifest not loaded — initialize first.'); - } - - const drive = this.driveList[driveIx]; - const adminHost = this.adminHost(publisher); - - adminMantaray.removeFork(getDriveForkPath(drive.id)); - adminMantaray.addFork(getDriveForkPath(drive.id), new Reference(drive.topic), driveForkMetadata(drive)); - - await this.store.saveMantarayNode(adminMantaray, adminHost, requestOptions); - } - - private async pruneTrashOverlay( - driveIx: number, - predicate: (entry: TrashEntry) => boolean, - requestOptions?: BeeRequestOptions, - ): Promise { - const drive = this.driveList[driveIx]; - const current = drive.trashedNodes ?? []; - const remaining = current.filter((e) => !predicate(e)); - if (remaining.length === current.length) { - return; - } - - drive.trashedNodes = remaining; - await this.persistAdminDriveFork(driveIx, requestOptions); - } - private adminHost(publisher: string): ManifestHost { const { stateFeedTopic } = assertReady(this.publisher, this.isInitialized, this.stateFeedTopic); if (!this.adminStamp) { diff --git a/src/mantarayStore.ts b/src/mantarayStore.ts index c921dbb..c69370a 100644 --- a/src/mantarayStore.ts +++ b/src/mantarayStore.ts @@ -12,7 +12,7 @@ import { import { type DriveInfo, type FileRecord, type FolderInfo, type ManifestHost, NodeType } from './types/info'; import { type ActReferences, type FeedResultWithIndex } from './types/utils'; import { assertActReferences, assertFileRecord } from './utils/asserts'; -import { getFeedData, writeActFeed } from './utils/bee'; +import { type FeedWriteResult, getFeedData, writeActFeed } from './utils/bee'; import { MANIFEST_METADATA_NODE_TOPIC, MANIFEST_METADATA_NODE_TYPE, @@ -31,7 +31,7 @@ export class MantarayStore { private readonly signerAddress: string; private readonly nodeManifestCache: Map = new Map(); private readonly nodeManifestLoading: Map> = new Map(); - private readonly nodeFeedIndexCache: Map = new Map(); + private readonly nodeNextIndexCache: Map = new Map(); private readonly nodeRefCache: Map = new Map(); // --- Initialization --- @@ -78,7 +78,7 @@ export class MantarayStore { manifestRef?: ActReferences, requestOptions?: BeeRequestOptions, ): Promise { - const cached = this.nodeManifestCache.get(topic); + const cached = this.getManifestCache(topic); if (cached) return cached; const inFlight = this.nodeManifestLoading.get(topic); @@ -98,8 +98,8 @@ export class MantarayStore { ); const node = await loadMantaray(this.bee, new Reference(raw), undefined, requestOptions); - this.nodeManifestCache.set(topic, node); - this.nodeRefCache.set(topic, manifestRef); + this.setManifestCache(topic, node); + this.setNodeRef(topic, manifestRef); return node; })(); @@ -117,31 +117,39 @@ export class MantarayStore { host: ManifestHost, requestOptions?: BeeRequestOptions, ): Promise { - const cachedWriteIx = this.nodeFeedIndexCache.get(host.topic); - const prevManifestRef = this.nodeRefCache.get(host.topic) ?? host.manifestRef; + const cachedWriteIx = this.getNodeNextIndexCache(host.topic); + const prevManifestRef = this.getNodeRef(host.topic) ?? host.manifestRef; - const { contentRefs, newIndex } = await saveNodeManifest( - this.bee, - this.signer, - node, - { ...host, manifestRef: prevManifestRef }, - cachedWriteIx, - requestOptions, - ); - this.nodeFeedIndexCache.set(host.topic, newIndex); - this.nodeRefCache.set(host.topic, contentRefs); + let contentRefs: ActReferences; + let nextIndex: bigint; + try { + ({ contentRefs, nextIndex } = await saveNodeManifest( + this.bee, + this.signer, + node, + { ...host, manifestRef: prevManifestRef }, + cachedWriteIx, + requestOptions, + )); + } catch (err: unknown) { + this.evict(host.topic); + throw err; + } + this.setNodeNextIndexCache(host.topic, nextIndex); + this.setNodeRef(host.topic, contentRefs); return contentRefs; } - async saveRecord(record: FileRecord, requestOptions?: BeeRequestOptions): Promise { - const prevRef = this.nodeRefCache.get(record.topic); + /** Returns the refs written plus the feed index they landed on — the record's authoritative version. */ + async saveRecord(record: FileRecord, requestOptions?: BeeRequestOptions): Promise { + const prevRef = this.getNodeRef(record.topic); const persistable: FileRecord = { ...record }; delete persistable.status; delete persistable.driveId; - const { contentRefs, newIndex } = await writeActFeed( + const { contentRefs, index, nextIndex } = await writeActFeed( this.bee, this.signer, JSON.stringify(persistable), @@ -154,16 +162,17 @@ export class MantarayStore { }, requestOptions, ); - this.nodeFeedIndexCache.set(record.topic, newIndex); - this.nodeRefCache.set(record.topic, contentRefs); + this.setNodeNextIndexCache(record.topic, nextIndex); + this.setNodeRef(record.topic, contentRefs); - return contentRefs; + return { contentRefs, index, nextIndex }; } async getRecord( topic: string, actPublisher: string, feedData: FeedResultWithIndex, + options: { isHeadRead: boolean }, requestOptions?: BeeRequestOptions, ): Promise { if (feedData.feedIndex.equals(FeedIndex.MINUS_ONE)) { @@ -189,8 +198,11 @@ export class MantarayStore { } record.version = feedData.feedIndex.toString(); - this.nodeRefCache.set(topic, contentRefs); - this.nodeFeedIndexCache.set(topic, new FeedIndex(record.version).next().toBigInt()); + + if (options.isHeadRead) { + this.setNodeRef(topic, contentRefs); + this.setNodeNextIndexCache(topic, new FeedIndex(record.version).next().toBigInt()); + } return record; } @@ -218,15 +230,20 @@ export class MantarayStore { } /** Prime the next feed-write index for `topic` (typically a probed `feedIndexNext`). */ - setNodeFeedIndex(topic: string, nextIndex: bigint): void { - this.nodeFeedIndexCache.set(topic, nextIndex); + setNodeNextIndexCache(topic: string, nextIndex: bigint): void { + this.nodeNextIndexCache.set(topic, nextIndex); + } + + /** The cached next feed-write index for `topic` */ + getNodeNextIndexCache(topic: string): bigint | undefined { + return this.nodeNextIndexCache.get(topic); } /** Clear all cached state */ evict(topic: string): void { this.nodeManifestCache.delete(topic); this.nodeManifestLoading.delete(topic); - this.nodeFeedIndexCache.delete(topic); + this.nodeNextIndexCache.delete(topic); this.nodeRefCache.delete(topic); } @@ -234,7 +251,7 @@ export class MantarayStore { clear(): void { this.nodeManifestCache.clear(); this.nodeManifestLoading.clear(); - this.nodeFeedIndexCache.clear(); + this.nodeNextIndexCache.clear(); this.nodeRefCache.clear(); } @@ -286,17 +303,7 @@ export class MantarayStore { if (!nodeTopic) { throw new FileRecordError(`Folder fork missing topic: ${currentPath}`); } - // Probe the feed head. A folder is a container and carries no stored version - const { - payload: folderPayload, - feedIndex: folderFeedIndex, - feedIndexNext: folderFeedIndexNext, - } = await getFeedData(this.bee, new Topic(nodeTopic), this.signerAddress, undefined, requestOptions); - if (folderFeedIndex.equals(FeedIndex.MINUS_ONE)) { - throw new DriveError(`Folder feed not found for path: ${currentPath}`); - } - const folderManifestRef: ActReferences = folderPayload.toJSON() as ActReferences; - assertActReferences(folderManifestRef); + const folderManifestRef = await this.resolveFolderManifestRef(nodeTopic, currentPath, requestOptions); currentFolderInfo = { type: NodeType.Folder, @@ -318,10 +325,37 @@ export class MantarayStore { currentFolderInfo.manifestRef, requestOptions, ); - - this.setNodeFeedIndex(nodeTopic, folderFeedIndexNext.toBigInt()); } return currentFolderInfo; } + + // A folder carries no stored version, so its manifest root comes from its feed head. + private async resolveFolderManifestRef( + nodeTopic: string, + currentPath: string, + requestOptions?: BeeRequestOptions, + ): Promise { + const cachedRef = this.getNodeRef(nodeTopic); + if (cachedRef && this.getManifestCache(nodeTopic) && this.getNodeNextIndexCache(nodeTopic) !== undefined) { + return cachedRef; + } + + const { payload, feedIndex, feedIndexNext } = await getFeedData( + this.bee, + new Topic(nodeTopic), + this.signerAddress, + undefined, + requestOptions, + ); + if (feedIndex.equals(FeedIndex.MINUS_ONE)) { + throw new DriveError(`Folder feed not found for path: ${currentPath}`); + } + + const manifestRef: ActReferences = payload.toJSON() as ActReferences; + assertActReferences(manifestRef); + this.setNodeNextIndexCache(nodeTopic, feedIndexNext.toBigInt()); + + return manifestRef; + } } diff --git a/src/types/fileManager.ts b/src/types/fileManager.ts index b15f14c..eb84a7f 100644 --- a/src/types/fileManager.ts +++ b/src/types/fileManager.ts @@ -21,7 +21,8 @@ import { type UpdateItem, type UploadFilesResult, type UploadItem } from './uplo */ export interface FileManager { /** - * Initializes the file manager. + * Initializes the file manager. Never rejects: failures are reported as `INITIALIZED false`, and + * all partial state is rolled back so the call can simply be retried. * @emits FileManagerEvents.INITIALIZED * @emits FileManagerEvents.STATE_INVALID * @returns A promise that resolves when the initialization is complete. @@ -83,10 +84,13 @@ export interface FileManager { * @param requestOptions - Additional Bee request options. * @emits FileManagerEvents.FILE_UPLOADED * @returns The newly-created FileRecord. - * @throws {DriveError} If not initialized, driveId is not found, or the target folder path does not exist. + * @throws {DriveError} If not initialized, driveId is not found, the target folder path does not + * exist, or a node already occupies `item.path` (fork keys are names, so names are unique + * within a folder — re-version with {@link updateFile} or relocate with {@link move}). * @throws {SignerError} If the publisher/signer is unavailable. * @throws {FileError} If the source is a directory, a node source path does not exist, or the content upload fails. - * @throws {FileRecordError} If a folder along the path has no feed. + * @throws {FileRecordError} If `item.path` is invalid or a folder along the path has no feed. + * @throws {FolderError} If the path is under the reserved `.trash` folder. */ uploadFile( driveId: string | Identifier, @@ -103,17 +107,22 @@ export interface FileManager { * are collected rather than aborting the whole batch. * @param driveId - The ID of the drive to upload into. * @param items - The files to upload, each with a path relative to destinationPath. - * @param destinationPath - Absolute path of the destination folder, or '/' for the drive root. + * Aborting rejects as soon as the signal is seen and no manifest is saved. + * @param destinationPath - Absolute path of the destination folder; defaults to the drive root. * @param uploadOptions - File-related upload options. * @param requestOptions - Additional Bee request options. * @emits FileManagerEvents.FOLDER_CREATED (per folder created) * @emits FileManagerEvents.FILE_UPLOADED (per file uploaded) * @emits FileManagerEvents.FILES_UPLOADED (once, with the batch summary) * @returns The succeeded FileRecords and any per-file failures. - * @throws {FileRecordError} If no items are given, an item path is invalid, or a folder fork is malformed. - * @throws {DriveError} If not initialized, driveId is not found, or a path segment is a file (not a folder). + * @throws {FileRecordError} If no items are given, an item path is invalid, two items resolve to + * the same destination path, or a folder fork is malformed. + * @throws {DriveError} If not initialized, driveId is not found, or a path segment is a file (not + * a folder). + * @throws {FolderError} If a destination is under the reserved `.trash` folder. * @throws {SignerError} If the publisher/signer is unavailable. - * Note: per-file content-upload failures are collected in `failed`, not thrown. + * Note: per-file content-upload failures are collected in `failed`, not thrown — as is an item + * whose destination name is already taken in the drive. An aborted signal rejects instead. */ uploadFiles( driveId: string | Identifier, @@ -135,8 +144,10 @@ export interface FileManager { * @param requestOptions - Additional Bee request options. * @emits FileManagerEvents.FILE_UPDATED * @returns The newly-written FileRecord for the updated version. - * @throws {FileRecordError} If neither new content (`item`) nor `customMetadata` is provided. + * @throws {FileRecordError} If neither new content (`item`) nor `customMetadata` is provided, the + * file is trashed, or the fork at the record's path belongs to a different node. * @throws {DriveError} If not initialized or driveId is not found. + * @throws {FolderError} If no fork exists at the record's path. * @throws {SignerError} If the publisher/signer is unavailable. * @throws {FileError} If the content upload fails. */ @@ -150,12 +161,14 @@ export interface FileManager { /** * Downloads every file in a folder subtree of a drive (resolved fresh via {@link listFolder}). + * Trashed files are skipped. * @param driveId - The ID of drive to download from. - * @param path - Absolute path of the folder; omitted = the whole drive. + * @param path - Absolute path of the folder; defaults to the drive root. * @param options - Optional download options. * @param requestOptions - Additional Bee request options. * @returns A promise that resolves to DownloadFilesResult, marking per file success and failure in the subtree. * @throws {DriveError} If not initialized, driveId is not found, or the folder path does not exist. + * @throws {FolderError} If the path is the reserved `.trash` folder. * @throws {SignerError} If the publisher/signer is unavailable. * @throws {FileRecordError} If a folder feed is missing. * Note: per-file download failures are logged, not thrown. @@ -204,13 +217,16 @@ export interface FileManager { /** * Lists entries in a folder (or drive root) in the drive manifest. * Also populates the recordList cache for any file entries encountered. + * Trashed nodes are not returned: the reserved `.trash` folder is omitted from the drive root and + * cannot be listed through this method — use {@link listTrash}. * @param driveId - The ID of the drive containing the folder. * @param path - Absolute path of the folder, or '/' for the drive root. * @param depth - Shallow (one level) or Deep (full BFS). Defaults to Shallow. - * @param maxDepth - Maximum BFS levels when depth is Deep; unlimited if omitted. + * @param maxDepth - Maximum BFS levels when depth is Deep; must be positive, unlimited if omitted. * @param requestOptions - Additional Bee request options. * @returns Array of {@link NodeEntry} (FileRecord | FolderInfo) for every node found at or below the given path. * @throws {DriveError} If not initialized, driveId is not found, or a path segment does not exist. + * @throws {FolderError} If the path is the reserved `.trash` folder, or `maxDepth` is not positive. * @throws {SignerError} If the publisher/signer is unavailable. * @throws {FileRecordError} If a folder feed is missing. */ @@ -223,61 +239,76 @@ export interface FileManager { ): Promise; /** - * Soft-delete: record a file in the drive's owner-private trash overlay so it is hidden from the - * active list. This is metadata-only — it does not touch the file's own feed or content. Recover with {@link recoverFile}. - * @param record - The file record describing the file to trash. - * @emits FileManagerEvents.FILE_TRASHED - * @throws {DriveError} If the FileManager is not initialized or the drive is not found. - * @throws {SignerError} If the publisher/signer is unavailable. - * @throws {FileRecordError} If the file is already trashed. - */ - trashFile(record: FileRecord, requestOptions?: BeeRequestOptions): Promise; - - /** - * Recover a previously trashed file back into the active list (removes it from the trash overlay). - * @param record - The file record describing the file to recover. - * @emits FileManagerEvents.FILE_RECOVERED - * @throws {DriveError} If the FileManager is not initialized or the drive is not found. + * Soft-delete a file or folder: relocates its fork into the drive's reserved `.trash` folder. + * + * @param driveId - The drive containing the node. + * @param path - Absolute path of the file or folder to trash. + * @emits FileManagerEvents.FILE_TRASHED or FileManagerEvents.FOLDER_TRASHED + * @throws {DriveError} If not initialized, the drive is not found, or a folder along the path does + * not exist. + * @throws {FolderError} If the path is the drive root, is already under `.trash`, or the node + * itself does not exist. * @throws {SignerError} If the publisher/signer is unavailable. - * @throws {FileRecordError} If the file is not currently trashed. + * @throws {FileRecordError} If the fork carries no node metadata. */ - recoverFile(record: FileRecord, requestOptions?: BeeRequestOptions): Promise; + trash(driveId: string | Identifier, path: string, requestOptions?: BeeRequestOptions): Promise; /** - * Soft-delete a folder: record only the folder's own topic in the drive's owner-private trash - * overlay. NO propagation — the subtree is untouched and costs a single overlay entry regardless - * of depth. The active {@link listFolder} hides the folder and stops descending into it; its - * contents reappear on {@link recoverFolder}. - * @param folder - The folder to trash (e.g. from {@link listFolder}). - * @emits FileManagerEvents.FOLDER_TRASHED - * @throws {DriveError} If the FileManager is not initialized or the drive is not found. + * Restore a trashed node to `toPath`, or back to the location it was trashed from when `toPath` is + * omitted. Restores location only — the node's content and version are whatever they were. + * + * The recorded origin can go stale: if that folder has since been forgotten, moved or trashed, + * resolution fails and the caller must pass an explicit `toPath`. An occupied destination is + * refused rather than overwritten. + * @param driveId - The drive containing the trashed node. + * @param trashedPath - The node's trashed path (`.trash/`), as returned by {@link listTrash}. + * @param toPath - Optional destination; defaults to the stamped origin path. + * @returns The path the node was restored to. + * @emits FileManagerEvents.FILE_RECOVERED or FileManagerEvents.FOLDER_RECOVERED + * @throws {DriveError} If not initialized, the drive is not found, the destination is already + * occupied, or the destination's parent folder no longer exists. + * @throws {FolderError} If the destination is under `.trash`. * @throws {SignerError} If the publisher/signer is unavailable. - * @throws {FileRecordError} If the folder is already trashed. + * @throws {FileRecordError} If `trashedPath` is not a `.trash/` path, the destination path + * is invalid, the node is not in the trash, or it has no recorded origin and no `toPath` was given. */ - trashFolder(folder: FolderInfo, requestOptions?: BeeRequestOptions): Promise; + recover( + driveId: string | Identifier, + trashedPath: string, + toPath?: string, + requestOptions?: BeeRequestOptions, + ): Promise; /** - * Recover a previously trashed folder (removes its topic from the trash overlay). Its subtree, - * which was never modified, becomes visible again. - * @param folder - The folder to recover (e.g. from {@link listTrash}). - * @emits FileManagerEvents.FOLDER_RECOVERED + * List a drive's trash. Walks the reserved `.trash` folder with the same machinery as + * {@link listFolder}, so `depth` controls the cost: Shallow returns the trashed roots only, Deep + * descends into trashed folders. Returns `[]` for a drive that has never had anything trashed. + * + * @param driveId - The drive whose trash to list. + * @param depth - Shallow (trashed roots only) or Deep (full BFS). Defaults to Shallow. + * @param maxDepth - Maximum BFS levels when depth is Deep; must be positive, unlimited if omitted. * @throws {DriveError} If the FileManager is not initialized or the drive is not found. + * @throws {FolderError} If `maxDepth` is not positive. * @throws {SignerError} If the publisher/signer is unavailable. - * @throws {FileRecordError} If the folder is not currently trashed. */ - recoverFolder(folder: FolderInfo, requestOptions?: BeeRequestOptions): Promise; + listTrash( + driveId: string | Identifier, + depth?: ListDepth, + maxDepth?: number, + requestOptions?: BeeRequestOptions, + ): Promise; /** - * List a drive's trashed nodes (files and folders), hydrated into full {@link NodeEntry} objects - * with `status` = trashed. Reads straight from the owner-private overlay with no tree walk, so the - * cost is proportional to the number of trashed roots, not the drive size. - * Recovery is honored per topic so visibility also requires ancestors to be recovered. - * @param driveId - The drive whose trash to list. - * @returns The trashed files and folders; pass one back to {@link recoverFile}/{@link recoverFolder}. + * De-reference every node in a drive's trash in one manifest write. Like {@link forget}, the + * content stays on Swarm until its stamp expires — this drops the references, it does not delete + * the data. + * @param driveId - The drive whose trash to empty. + * @returns The number of trashed nodes that were de-referenced. + * @emits FileManagerEvents.TRASH_EMPTIED * @throws {DriveError} If the FileManager is not initialized or the drive is not found. * @throws {SignerError} If the publisher/signer is unavailable. */ - listTrash(driveId: string | Identifier, requestOptions?: BeeRequestOptions): Promise; + emptyTrash(driveId: string | Identifier, requestOptions?: BeeRequestOptions): Promise; /** * Hard-delete a file or folder at the given path from the drive manifest and in-memory state. @@ -286,9 +317,12 @@ export interface FileManager { * @param path - Absolute path of the file or folder to remove. * @param requestOptions - Additional Bee request options. * @emits FileManagerEvents.FILE_FORGOTTEN (file) or FileManagerEvents.FOLDER_FORGOTTEN (folder) - * @throws {DriveError} If not initialized, driveId is not found, the path is the drive root, or the path does not exist. + * @throws {DriveError} If not initialized, driveId is not found, or a folder along the path does + * not exist. + * @throws {FolderError} If the path is the drive root, or the reserved `.trash` folder — emptying + * the trash goes through {@link emptyTrash}. * @throws {SignerError} If the publisher/signer is unavailable. - * @throws {FileRecordError} If a folder feed is missing. + * @throws {FileRecordError} If the path does not exist or a folder feed is missing. */ forget(driveId: string | Identifier, path: string, requestOptions?: BeeRequestOptions): Promise; @@ -306,8 +340,8 @@ export interface FileManager { /** * Returns a specific version of a file. * - * @param record - The base FileRecord containing topic and owner fields. - * @param version - Optional desired version slot as a FeedIndex or hex/string. If omitted, fetches latest. + * @param record - The base FileRecord containing topic, owner and the node's current path. + * @param version - Optional desired version slot as a FeedIndex or its 16-hex-character string. If omitted, fetches latest. * @returns The FileRecord corresponding to the requested version, either cached or fetched. * @throws {DriveError} If the FileManager is not initialized. * @throws {SignerError} If the publisher/signer is unavailable. @@ -326,22 +360,28 @@ export interface FileManager { * @param requestOptions - Optional BeeRequestOptions for upload operations. * @emits FileManagerEvents.FILE_VERSION_RESTORED * @throws {DriveError} If the FileManager is not initialized. + * @throws {FolderError} If the file's fork cannot be found at its current path. * @throws {SignerError} If the publisher/signer is unavailable. - * @throws {FileRecordError} If the feed is not found, the restore version is undefined, or it is the current head. + * @throws {FileRecordError} If the feed is not found, the restore version is undefined, it is the + * current head, or the fork at the resolved path belongs to a different node. */ restoreFileVersion(versionToRestore: FileRecord, requestOptions?: BeeRequestOptions): Promise; /** - * Moves a file or folder within a drive from one path to another. + * Moves a file or folder within a drive from one path to another. There is no cross-drive move: a + * relocated node keeps its drive's stamp, so both paths resolve against `sourceDriveId` and a path + * from another drive is simply not found — {@link forget} it and re-upload to the other drive. * * @param fromPath - Absolute path of the entry within the drive manifest. * @param toPath - Destination path within the drive manifest. - * @param sourceDriveId - The ID of the drive containing the source path. - * @param targetDriveId - Optional target ID drive for cross-drive moves; defaults to sourceDriveInfo. + * @param sourceDriveId - The ID of the drive containing both paths. * @param requestOptions - Optional BeeRequestOptions for upload operations. - * @emits FileManagerEvents.FILE_MOVED - * @throws {DriveError} If not initialized, a source/target driveId is not found, the source is the - * root, the destination is invalid, source and destination are identical, or a path does not exist. + * @emits FileManagerEvents.FILE_MOVED (file) or FileManagerEvents.FOLDER_MOVED (folder) + * @throws {DriveError} If not initialized, the driveId is not found, or a folder along either path + * does not exist. + * @throws {FolderError} If the source is the root, the destination is invalid, source and + * destination are identical, the source does not exist, the destination is already occupied, or + * either path is under the reserved `.trash` folder — trashing goes through {@link trash}. * @throws {SignerError} If the publisher/signer is unavailable. * @throws {FileRecordError} If a folder feed or the source file record is missing. */ @@ -349,7 +389,6 @@ export interface FileManager { fromPath: string, toPath: string, sourceDriveId: string | Identifier, - targetDriveId?: string | Identifier, requestOptions?: BeeRequestOptions, ): Promise; @@ -362,7 +401,9 @@ export interface FileManager { * @param requestOptions - Additional Bee request options. * @emits FileManagerEvents.FOLDER_CREATED * @returns The FolderInfo for the newly created folder. - * @throws {DriveError} If not initialized, driveId is not found, the folder name is invalid, or the parent path does not exist. + * @throws {DriveError} If not initialized, driveId is not found, or the parent path does not exist. + * @throws {FolderError} If the folder name is invalid or reserved (`.trash`), or a node already + * occupies that name. * @throws {SignerError} If the publisher/signer is unavailable. * @throws {FileRecordError} If a folder feed is missing. */ diff --git a/src/types/index.ts b/src/types/index.ts index 2ae5cca..8ec1f55 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,13 +1,4 @@ -export type { - DriveInfo, - FileRecord, - FolderInfo, - ManifestHost, - NodeEntry, - NodeHeader, - NodeResource, - TrashEntry, -} from './info'; +export type { DriveInfo, FileRecord, FolderInfo, ManifestHost, NodeEntry, NodeHeader, NodeResource } from './info'; export { ListDepth, NodeType, NodeStatus } from './info'; export type { BrowserUploadOptions, diff --git a/src/types/info.ts b/src/types/info.ts index 0f737fe..ecb638b 100644 --- a/src/types/info.ts +++ b/src/types/info.ts @@ -1,4 +1,4 @@ -import { type RedundancyLevel } from '@ethersphere/bee-js'; +import { type MantarayNode, type RedundancyLevel } from '@ethersphere/bee-js'; import { type ActReferences } from './utils'; @@ -35,6 +35,7 @@ export interface FileRecord extends NodeResource { content: ActReferences; timestamp?: number; customMetadata?: Record; + trashedFrom?: string; } export interface ManifestHost extends NodeResource { @@ -42,25 +43,18 @@ export interface ManifestHost extends NodeResource { version?: never; } -export interface TrashEntry { - topic: string; - type: NodeType; - path: string; - version?: string; -} - export interface DriveInfo extends ManifestHost { type: NodeType.Drive; id: string; name: string; isAdmin: boolean; - trashedNodes?: TrashEntry[]; } export interface FolderInfo extends ManifestHost { type: NodeType.Folder; path: string; driveId: string; + trashedFrom?: string; } export type NodeEntry = FileRecord | FolderInfo; @@ -75,3 +69,12 @@ export interface NodeHeader { head?: ActReferences; rawMetadata: Record; } + +export interface ResolvedFileFork { + host: ManifestHost; + folder: FolderInfo | null; + node: MantarayNode; + filename: string; + targetAddress: Uint8Array; + metadata: Record; +} diff --git a/src/utils/asserts.ts b/src/utils/asserts.ts index 15c097e..b32e470 100644 --- a/src/utils/asserts.ts +++ b/src/utils/asserts.ts @@ -1,6 +1,7 @@ import { BatchId, EthAddress, + FeedIndex, Identifier, PublicKey, type RedundancyLevel, @@ -16,7 +17,6 @@ import { type NodeResource, NodeStatus, NodeType, - type TrashEntry, } from '../types/info'; import { type ActReferences } from '../types/utils'; @@ -27,7 +27,6 @@ import { MANIFEST_METADATA_DRIVE_IS_ADMIN, MANIFEST_METADATA_DRIVE_NAME, MANIFEST_METADATA_DRIVE_OWNER, - MANIFEST_METADATA_DRIVE_TRASHED_NODES, MANIFEST_METADATA_NODE_TOPIC, MANIFEST_METADATA_REDUNDANCY_LEVEL, } from './constants'; @@ -84,8 +83,11 @@ export function assertFileRecord(value: unknown): asserts value is FileRecord { throw new TypeError('path property of FileRecord has to be a non-empty string!'); } - if (fr.version !== undefined && typeof fr.version !== 'string') { - throw new TypeError('version property of FileRecord has to be string!'); + if (fr.version !== undefined) { + if (typeof fr.version !== 'string') { + throw new TypeError('version property of FileRecord has to be string!'); + } + new FeedIndex(fr.version); } if (fr.customMetadata !== undefined && !isRecord(fr.customMetadata)) { @@ -169,42 +171,12 @@ export function assertDriveInfoFromMetadata(meta: Record): Drive redundancyLevel, topic, actPublisher, - trashedNodes: parseTrashedNodes(meta[MANIFEST_METADATA_DRIVE_TRASHED_NODES]), }; assertDriveInfo(driveInfo); return driveInfo; } -export function parseTrashedNodes(raw?: string): TrashEntry[] { - if (!raw) { - return []; - } - - try { - const parsed = JSON.parse(raw); - if (!Array.isArray(parsed)) { - return []; - } - - return (parsed as unknown[]) - .filter( - (e): e is Record => - Types.isStrictlyObject(e) && - typeof (e as Record).topic === 'string' && - ((e as Record).topic as string).length > 0, - ) - .map((e) => ({ - topic: e.topic as string, - type: (e.type as NodeType) ?? NodeType.File, - version: typeof e.version === 'string' ? (e.version as string) : undefined, - path: typeof e.path === 'string' ? (e.path as string) : '', - })); - } catch { - return []; - } -} - interface FMReadyState { publisher: string; isInitialized: boolean; diff --git a/src/utils/bee.ts b/src/utils/bee.ts index 803e9ca..c2f5061 100644 --- a/src/utils/bee.ts +++ b/src/utils/bee.ts @@ -15,7 +15,7 @@ import { type ActReferences, type FeedResultWithIndex } from '../types/utils'; import { isNotFoundError } from './common'; import { FEED_INDEX_ZERO, SWARM_ZERO_ADDRESS } from './constants'; import { generateRandomBytes } from './crypto'; -import { ErrorHandler, StampError } from './errors'; +import { BeeVersionError, ErrorHandler, StampError } from './errors'; import { Logger } from './logger'; const logger = Logger.getInstance(); @@ -94,13 +94,19 @@ export interface FeedTarget { index?: bigint; } +export interface FeedWriteResult { + contentRefs: ActReferences; + index: bigint; + nextIndex: bigint; +} + export async function writeActFeed( bee: Bee, signer: PrivateKey, payload: string | Uint8Array, target: FeedTarget, requestOptions?: BeeRequestOptions, -): Promise<{ contentRefs: ActReferences; newIndex: bigint }> { +): Promise { const upload = await bee.uploadData( target.batchId, payload, @@ -127,7 +133,7 @@ export async function writeActFeed( const fw = bee.makeFeedWriter(new Topic(target.topic).toUint8Array(), signer, requestOptions); await fw.uploadPayload(target.batchId, JSON.stringify(contentRefs), { index: FeedIndex.fromBigInt(writeIndex) }); - return { contentRefs, newIndex: writeIndex + 1n }; + return { contentRefs, index: writeIndex, nextIndex: writeIndex + 1n }; } export async function fetchStamp( @@ -165,6 +171,6 @@ export async function verifySupportedBeeVersions(bee: Bee, requestOptions?: BeeR if (!supportedApi) { logger.error('Supported bee API version: ', beeVersions.supportedBeeApiVersion); logger.error('Supported bee version: ', beeVersions.supportedBeeVersion); - // throw new BeeVersionError('Bee or Bee API version not supported'); + throw new BeeVersionError('Bee or Bee API version not supported'); } } diff --git a/src/utils/common.ts b/src/utils/common.ts index 0dd3edd..1f056fd 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -1,6 +1,7 @@ -import { type DriveInfo, NodeStatus } from '../types/info'; +import { NodeStatus } from '../types/info'; import { Logger } from './logger'; +import { isTrashPath } from './path'; const logger = Logger.getInstance(); @@ -34,14 +35,24 @@ export const joinPath = (base: string, name: string): string => { return base ? `${base}/${name}` : name; }; -export const getRecordStatus = (drive: DriveInfo, topic: string): NodeStatus => { - const isFoundInTrash = !!drive.trashedNodes?.some((n) => n.topic === topic); - return isFoundInTrash ? NodeStatus.Trashed : NodeStatus.Active; +export const getRecordStatus = (recordPath: string): NodeStatus => { + return isTrashPath(recordPath) ? NodeStatus.Trashed : NodeStatus.Active; }; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function isNotFoundError(error: any): boolean { - return error.stack?.includes('404') || error.message?.includes('Not Found') || error.message?.includes('404'); +const HTTP_NOT_FOUND = 404; + +const toStatusCode = (value: unknown): number | undefined => { + if (typeof value === 'number') return value; + if (typeof value === 'string' && /^\d+$/.test(value)) return Number(value); + return undefined; +}; + +export function isNotFoundError(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + + const { status, response } = error as { status?: unknown; response?: { status?: unknown } }; + + return toStatusCode(status) === HTTP_NOT_FOUND || toStatusCode(response?.status) === HTTP_NOT_FOUND; } export async function settlePromises( @@ -62,7 +73,3 @@ export async function settlePromises( } }); } - -export const getEncodedSize = (input: string): number => { - return new TextEncoder().encode(input).length; -}; diff --git a/src/utils/constants.ts b/src/utils/constants.ts index fcddc76..e6b703e 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1,10 +1,11 @@ import { FeedIndex, NULL_ADDRESS, Reference, Topic } from '@ethersphere/bee-js'; export const FILEMANAGER_STATE_TOPIC = Topic.fromString('filemanager-state'); -export const ADMIN_STAMP_LABEL = 'admin'; +export const ADMIN_DRIVE_NAME = 'admin'; export const SWARM_ZERO_ADDRESS = new Reference(NULL_ADDRESS); export const FEED_INDEX_ZERO = FeedIndex.fromBigInt(0n); export const ROOT_PATH = '/'; +export const TRASH_FOLDER_NAME = '.trash'; export const MAX_CONCURRENT_FEED_FETCHES = 10; export const MAX_CONCURRENT_UPLOADS = 2; export const DRIVE_FORK_PREFIX = '/drive'; @@ -15,10 +16,10 @@ export const MANIFEST_METADATA_REDUNDANCY_LEVEL = 'swarm-redundancy-level'; export const MANIFEST_METADATA_NODE_OWNER = 'swarm-node-owner'; export const MANIFEST_METADATA_NODE_ACT_PUBLISHER = 'swarm-node-act-publisher'; export const MANIFEST_METADATA_NODE_VERSION = 'swarm-node-version'; +export const MANIFEST_METADATA_TRASHED_FROM = 'swarm-trashed-from'; export const MANIFEST_METADATA_DRIVE_ID = 'swarm-drive-id'; export const MANIFEST_METADATA_DRIVE_NAME = 'swarm-drive-name'; export const MANIFEST_METADATA_DRIVE_OWNER = 'swarm-drive-owner'; export const MANIFEST_METADATA_DRIVE_IS_ADMIN = 'swarm-drive-is-admin'; export const MANIFEST_METADATA_DRIVE_BATCH_ID = 'swarm-drive-batch-id'; export const MANIFEST_METADATA_DRIVE_ACT_PUBLISHER = 'swarm-drive-act-publisher'; -export const MANIFEST_METADATA_DRIVE_TRASHED_NODES = 'swarm-drive-trashed-nodes'; diff --git a/src/utils/events.ts b/src/utils/events.ts index 49757b9..22c7870 100644 --- a/src/utils/events.ts +++ b/src/utils/events.ts @@ -1,19 +1,20 @@ export enum FileManagerEvents { + INITIALIZED = 'initialized', + STATE_INVALID = 'state-invalid', FILE_UPLOADED = 'file-uploaded', FILE_UPDATED = 'file-updated', - FILE_DOWNLOADED = 'file-downloaded', FILE_TRASHED = 'file-trashed', FILE_RECOVERED = 'file-recovered', FILE_FORGOTTEN = 'file-forgotten', FILE_VERSION_RESTORED = 'file-version-restored', FILE_MOVED = 'file-moved', - INITIALIZED = 'initialized', - DRIVE_CREATED = 'drive-created', - DRIVE_FORGOTTEN = 'drive-forgotten', + FILES_UPLOADED = 'files-uploaded', + FOLDER_MOVED = 'folder-moved', FOLDER_FORGOTTEN = 'folder-forgotten', FOLDER_TRASHED = 'folder-trashed', FOLDER_RECOVERED = 'folder-recovered', FOLDER_CREATED = 'folder-created', - FILES_UPLOADED = 'files-uploaded', - STATE_INVALID = 'state-invalid', + DRIVE_CREATED = 'drive-created', + DRIVE_FORGOTTEN = 'drive-forgotten', + TRASH_EMPTIED = 'trash-emptied', } diff --git a/src/utils/fs/fs-node.ts b/src/utils/fs/fs-node.ts index f222c77..450bfd7 100644 --- a/src/utils/fs/fs-node.ts +++ b/src/utils/fs/fs-node.ts @@ -2,42 +2,9 @@ import type { ReadStream } from 'fs'; import { FileError } from '../errors'; -export interface FileData { +interface FileData { data: ReadStream; name: string; - contentType: string; -} - -const contentTypes: Map = new Map([ - ['.mp4', 'video/mp4'], - ['.webm', 'video/webm'], - ['.ogv', 'video/ogg'], - ['.mp3', 'audio/mpeg'], - ['.m4a', 'audio/mp4'], - ['.aac', 'audio/aac'], - ['.wav', 'audio/wav'], - ['.ogg', 'audio/ogg'], - ['.png', 'image/png'], - ['.jpg', 'image/jpeg'], - ['.jpeg', 'image/jpeg'], - ['.gif', 'image/gif'], - ['.webp', 'image/webp'], - ['.avif', 'image/avif'], - ['.svg', 'image/svg+xml'], - ['.pdf', 'application/pdf'], - ['.txt', 'text/plain'], - ['.md', 'text/markdown'], - ['.json', 'application/json'], - ['.csv', 'text/csv'], - ['.html', 'text/html'], - ['.htm', 'text/html'], -]); - -export async function getContentType(filePath: string): Promise { - const { extname } = await import('path'); - const ext = extname(filePath).toLowerCase(); - - return contentTypes.get(ext) || 'application/octet-stream'; } export async function isDir(dirPath: string): Promise { @@ -56,7 +23,6 @@ export async function readFile(filePath: string): Promise { const readable = createReadStream(filePath); const fileName = basename(filePath); - const contentType = await getContentType(filePath); - return { data: readable, name: fileName, contentType }; + return { data: readable, name: fileName }; } diff --git a/src/utils/index.ts b/src/utils/index.ts index 2f67cd8..a5e5d87 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,5 +1,5 @@ export { FileManagerEvents } from './events'; -export { ADMIN_STAMP_LABEL, FILEMANAGER_STATE_TOPIC } from './constants'; +export { ADMIN_DRIVE_NAME, FILEMANAGER_STATE_TOPIC } from './constants'; export { BeeVersionError, DriveError, diff --git a/src/utils/mantaray.ts b/src/utils/mantaray.ts index f3829d4..d1eeee2 100644 --- a/src/utils/mantaray.ts +++ b/src/utils/mantaray.ts @@ -16,9 +16,9 @@ import { type NodeHeader, NodeType, } from '../types/info'; -import { type ActReferences } from '../types/utils'; -import { writeActFeed } from './bee'; +import { type FeedWriteResult, writeActFeed } from './bee'; +import { getRecordStatus } from './common'; import { DRIVE_FORK_PREFIX, MANIFEST_METADATA_DRIVE_ACT_PUBLISHER, @@ -27,7 +27,6 @@ import { MANIFEST_METADATA_DRIVE_IS_ADMIN, MANIFEST_METADATA_DRIVE_NAME, MANIFEST_METADATA_DRIVE_OWNER, - MANIFEST_METADATA_DRIVE_TRASHED_NODES, MANIFEST_METADATA_FILE_TOPIC, MANIFEST_METADATA_NODE_ACT_PUBLISHER, MANIFEST_METADATA_NODE_OWNER, @@ -35,6 +34,7 @@ import { MANIFEST_METADATA_NODE_TYPE, MANIFEST_METADATA_NODE_VERSION, MANIFEST_METADATA_REDUNDANCY_LEVEL, + MANIFEST_METADATA_TRASHED_FROM, } from './constants'; export async function loadMantaray( @@ -72,11 +72,6 @@ export function getAllNodeEntries(root: MantarayNode): NodeHeader[] { .filter((e): e is NodeHeader => e !== null); } -export interface SavedManifest { - contentRefs: ActReferences; - newIndex: bigint; -} - export async function saveNodeManifest( bee: Bee, signer: PrivateKey, @@ -84,7 +79,7 @@ export async function saveNodeManifest( host: ManifestHost, index?: bigint, requestOptions?: BeeRequestOptions, -): Promise { +): Promise { const saveResult = await node.saveRecursively(bee, host.batchId, undefined, requestOptions); return writeActFeed( @@ -123,6 +118,26 @@ export function folderForkMetadata(folder: FolderInfo): Record { }; } +export function folderInfoFromMetadata( + meta: Record, + drive: DriveInfo, + path: string, + fallback: { owner: string; actPublisher: string }, +): FolderInfo { + return { + type: NodeType.Folder, + topic: meta[MANIFEST_METADATA_NODE_TOPIC], + owner: meta[MANIFEST_METADATA_NODE_OWNER] ?? fallback.owner, + actPublisher: meta[MANIFEST_METADATA_NODE_ACT_PUBLISHER] ?? fallback.actPublisher, + batchId: drive.batchId, + redundancyLevel: getRlevel(meta, drive.redundancyLevel), + path, + driveId: drive.id, + status: getRecordStatus(path), + ...(meta[MANIFEST_METADATA_TRASHED_FROM] ? { trashedFrom: meta[MANIFEST_METADATA_TRASHED_FROM] } : {}), + }; +} + export function driveForkMetadata(drive: DriveInfo): Record { return { [MANIFEST_METADATA_NODE_TOPIC]: drive.topic, @@ -134,7 +149,6 @@ export function driveForkMetadata(drive: DriveInfo): Record { [MANIFEST_METADATA_DRIVE_BATCH_ID]: drive.batchId, [MANIFEST_METADATA_DRIVE_ACT_PUBLISHER]: drive.actPublisher, [MANIFEST_METADATA_REDUNDANCY_LEVEL]: drive.redundancyLevel.toString(), - [MANIFEST_METADATA_DRIVE_TRASHED_NODES]: JSON.stringify(drive.trashedNodes ?? []), }; } diff --git a/src/utils/path.ts b/src/utils/path.ts index c4d3542..0c47767 100644 --- a/src/utils/path.ts +++ b/src/utils/path.ts @@ -1,5 +1,5 @@ -import { ROOT_PATH } from './constants'; -import { FileRecordError } from './errors'; +import { ROOT_PATH, TRASH_FOLDER_NAME } from './constants'; +import { FileRecordError, FolderError } from './errors'; export function pathSegments(path: string): string[] { return path.split('/').filter(Boolean); @@ -17,8 +17,31 @@ export function splitPath(path: string): { parentPath: string; name: string } { }; } +export function assertValidNodePath(path: string): void { + const segments = pathSegments(path); + if (!path || path.endsWith('/') || segments.length === 0 || segments.some((s) => s === '.' || s === '..')) { + throw new FileRecordError(`Invalid path: "${path}"`); + } +} + export function assertValidRelativePath(path: string): void { - if (!path || path.startsWith('/') || path.includes('..') || path.endsWith('/')) { + if (path.startsWith('/')) { throw new FileRecordError(`Invalid path: "${path}"`); } + + assertValidNodePath(path); +} + +export function isTrashPath(path: string): boolean { + return pathSegments(path)[0] === TRASH_FOLDER_NAME; +} + +export function assertNotTrashPath(path: string): void { + if (isTrashPath(path)) { + throw new FolderError(`"${TRASH_FOLDER_NAME}" is reserved — use trash/recover and listTrash`); + } +} + +export function trashPathOf(topic: string): string { + return `${TRASH_FOLDER_NAME}/${topic}`; } diff --git a/tests/TESTS.md b/tests/TESTS.md index 84b0ddd..127497d 100644 --- a/tests/TESTS.md +++ b/tests/TESTS.md @@ -15,7 +15,7 @@ and troubleshoot it. It covers both **unit** and **integration** tests (includin `unit-node` and `unit-browser` execute the same specs, the latter adding `tests/platform-browser.ts` to shim browser globals so the platform-split code is exercised both ways. - **Integration tests** run against real Bee nodes provisioned by **`@ethersphere/bee-factory`** and exercise ACT - encryption, per‑file feeds, mantaray drive manifests, versioning and the trash overlay end‑to‑end. + encryption, per‑file feeds, mantaray drive manifests, versioning and the reserved `.trash` folder end‑to‑end. - The runner uses **`--maxWorkers=4`**; integration steps lean on the 5-minute `testTimeout` + propagation retries rather than serial execution. - `testTimeout` is **5 minutes** per test (integration steps wait on chunk propagation). @@ -145,8 +145,8 @@ Each domain area lives in its own spec file, mirrored across `unit/` and `integr - **Drives are mantaray manifests.** A drive's file tree is a mantaray whose forks carry per-file metadata; per-file version history lives in each file's own Swarm feed. - **ACT** wraps content per file (`content.historyRef`, `actPublisher`). -- **Trash is an owner-private overlay** on the admin drive: status is _derived_ on load (not stored on the file's own - feed), so a fresh instance re-derives Active/Trashed via `listFolder`. +- **Trash is a reserved `.trash` folder** at the drive root: trashing relocates the node's fork into it keyed by topic, + so status is _derived_ from a node's location and a fresh instance sees it by walking the tree. - `FileManagerConfig` lets clients cap `uploadConcurrency` and `feedFetchConcurrency`. - Sharing / grantees are **not** part of v2 and are not tested. @@ -165,15 +165,16 @@ Executed against live bee-factory nodes. - **`file.spec.ts`** — split into `uploadFile`, `uploadFiles`, `updateFile`, `downloadFile and downloadFiles`, `move`: single- and multi-file uploads (each with its own topic), implicit folder creation with batched manifest saves, the two-hop ACT-unwrap download round-trip, `updateFile` re-versioning (content vs. metadata-only), directory-source - guards, and rename/move within and across drives. + guards, rename/move within a drive, and a foreign-drive path failing to resolve (there is no cross-drive move). - **`folder.spec.ts`** — _Folder operations_: `listFolder` (relative paths, empty folders, deep nesting, empty-path rejection), `downloadFolder` destination-path composition, and moving a folder as a unit. - **`version.spec.ts`** — _Version control_: invalid index rejection, sequential slot indices, cold-cache lazy hydration, drive-mismatch guard, independently downloadable version bytes, cached-head fast path, restoring a prior version as the new head, no-op restore of the head, and restore keeping the current (post-move) location. -- **`trash.spec.ts`** — _Lifecycle management_: trash (soft-delete) and recover round-trip through the owner-private - overlay (status re-derived by a fresh instance, **no** version bump), `forget` (hard-delete), and no-duplicate-topic - guarantees. +- **`trash.spec.ts`** — _Lifecycle management_: trash/recover round-trips through the `.trash` folder (a fresh instance + stops listing the node and finds it via `listTrash`, **no** version bump), folder trash carrying its subtree, + same-named nodes kept apart, recover to an explicit destination after the origin was forgotten, the write guards, + `emptyTrash`, and `forget` (hard de-reference). - **`abort.spec.ts`** — _Abort signal handling_: `AbortSignal` forwarding for `uploadFile`, `downloadFiles`, and `listFolder` — pre-aborted, mid-flight cancel, and clean completion when not aborted. - **`e2e.spec.ts`** — _End-to-End User Workflow_: in-place folder update (one file changes, siblings untouched), adding @@ -201,15 +202,17 @@ Key strategies: - **`folder.spec.ts`** — `downloadFolder`, `listFolder`, `createFolder`, `move`. - **`version.spec.ts`** — `getFileVersion` (indexed vs. head, cache reuse, missing-feed error), `restoreFileVersion` (head restore is a no-op / emits no event). -- **`trash.spec.ts`** — _Lifecycle management_ → `trashFile`, `recoverFile`, `trashFolder`, `listTrash`, `forget` (event - emission and overlay bookkeeping). +- **`trash.spec.ts`** — _Lifecycle management_ → `trash`, `recover`, `listTrash`, `emptyTrash`, `forget` (fork + relocation, origin stamping and event emission). - **`events.spec.ts`** — _Events and emitter_: deterministic `FILE_UPLOADED` payloads (system time pinned via `jest.useFakeTimers()`), `INITIALIZED` fired once per cold init. - **`abort.spec.ts`** — abort-signal plumbing at the unit level. -Emitted events live in `FileManagerEvents` (`src/utils/events.ts`): `FILE_UPLOADED`, `FILE_UPDATED`, `FILE_DOWNLOADED`, -`FILE_TRASHED`, `FILE_RECOVERED`, `FILE_FORGOTTEN`, `FILE_VERSION_RESTORED`, `FILE_MOVED`, `INITIALIZED`, -`DRIVE_CREATED`, `DRIVE_FORGOTTEN`, `DRIVE_DESTROYED`, `FOLDER_*`, `FILES_UPLOADED`, `STATE_INVALID`. +Emitted events live in `FileManagerEvents` (`src/utils/events.ts`): `FILE_UPLOADED`, `FILE_UPDATED`, `FILE_TRASHED`, +`FILE_RECOVERED`, `FILE_FORGOTTEN`, `FILE_VERSION_RESTORED`, `FILE_MOVED`, `INITIALIZED`, `DRIVE_CREATED`, +`DRIVE_FORGOTTEN`, `FOLDER_*` (including `FOLDER_MOVED`), `FILES_UPLOADED`, `TRASH_EMPTIED`, `STATE_INVALID`. The +file/folder pairs of a path-addressed operation carry the same payload shape — see +[REFERENCE.md](../REFERENCE.md#events). --- diff --git a/tests/integration/abort.spec.ts b/tests/integration/abort.spec.ts index 1c40b62..e8c6d8f 100644 --- a/tests/integration/abort.spec.ts +++ b/tests/integration/abort.spec.ts @@ -2,9 +2,12 @@ import { type Bee, type PublicKey } from '@ethersphere/bee-js'; import path from 'path'; import { setTimeout } from 'timers'; +import { abortAfterFirstRecordWrite, retryOnPropagationDelay } from '../utils'; + import { setupUserDrive, tempFileRegistry } from './setup/utils'; -import { type FileManagerBase } from '@/fileManager'; +import { EventEmitterBase } from '@/eventEmitter'; +import { FileManagerBase } from '@/fileManager'; import { type DriveInfo, type FileRecord, type FolderInfo, ListDepth } from '@/types'; import { ROOT_PATH } from '@/utils/constants'; @@ -134,6 +137,100 @@ describe('Abort signal handling', () => { }); }); + describe('uploadFiles', () => { + it('leaves the drive untouched when the batch is aborted mid-flight', async () => { + const one = writeTempFile('it-abortbatch-one.txt', 'Abort batch one'); + const two = writeTempFile('it-abortbatch-two.txt', 'Abort batch two'); + const three = writeTempFile('it-abortbatch-three.txt', 'Abort batch three'); + + // uploadConcurrency 1 keeps the batch sequential, so the abort lands between files. + const fm = new FileManagerBase(bee, new EventEmitterBase(), { uploadConcurrency: 1 }); + await fm.initialize(); + const localDrive = fm.driveList.find((d) => d.id === drive.id); + expect(localDrive).toBeDefined(); + const manifestRefBefore = { ...localDrive!.manifestRef }; + + const controller = new AbortController(); + abortAfterFirstRecordWrite(fm, controller); + + await expect( + fm.uploadFiles( + drive.id, + [ + { path: 'abortbatch/one.txt', sourcePath: one }, + { path: 'abortbatch/two.txt', sourcePath: two }, + { path: 'abortbatch/three.txt', sourcePath: three }, + ], + '', + undefined, + { signal: controller.signal }, + ), + ).rejects.toThrow(); + + expect(fm.recordList.some((fr) => fr.path.startsWith('abortbatch/'))).toBe(false); + expect(fm.driveList.find((d) => d.id === drive.id)!.manifestRef).toEqual(manifestRefBefore); + + const verifier = new FileManagerBase(bee); + await verifier.initialize(); + const rootEntries = await verifier.listFolder(drive.id, ROOT_PATH, ListDepth.Shallow); + expect(rootEntries.some((e) => e.path === 'abortbatch')).toBe(false); + }); + + it('a later unrelated write does not commit an aborted batch after the fact', async () => { + const aborted = writeTempFile('it-abortbatch-later-aborted.txt', 'Aborted content'); + const kept = writeTempFile('it-abortbatch-later-kept.txt', 'Kept content'); + + const fm = new FileManagerBase(bee, new EventEmitterBase(), { uploadConcurrency: 1 }); + await fm.initialize(); + + const controller = new AbortController(); + abortAfterFirstRecordWrite(fm, controller); + + await expect( + fm.uploadFiles( + drive.id, + [ + { path: 'later-abort/gone.txt', sourcePath: aborted }, + { path: 'later-abort/also-gone.txt', sourcePath: aborted }, + ], + '', + undefined, + { signal: controller.signal }, + ), + ).rejects.toThrow(); + + await fm.uploadFile(drive.id, { path: 'it-abortbatch-later-kept.txt', sourcePath: kept }); + + const verifier = await retryOnPropagationDelay(async () => { + const fresh = new FileManagerBase(bee); + await fresh.initialize(); + const entries = await fresh.listFolder(drive.id, ROOT_PATH, ListDepth.Shallow); + if (!entries.some((e) => e.path === 'it-abortbatch-later-kept.txt')) { + throw new Error('follow-up upload not yet propagated'); + } + return fresh; + }); + + const entries = await verifier.listFolder(drive.id, ROOT_PATH, ListDepth.Shallow); + expect(entries.some((e) => e.path === 'it-abortbatch-later-kept.txt')).toBe(true); + expect(entries.some((e) => e.path === 'later-abort')).toBe(false); + }); + + it('rejects without uploading anything when the signal is pre-aborted', async () => { + const src = writeTempFile('it-abortbatch-pre.txt', 'Never uploaded'); + const controller = new AbortController(); + controller.abort(); + + await expect( + fileManager.uploadFiles(drive.id, [{ path: 'abortbatch-pre/x.txt', sourcePath: src }], '', undefined, { + signal: controller.signal, + }), + ).rejects.toThrow(); + + expect(fileManager.recordList.some((fr) => fr.path.startsWith('abortbatch-pre/'))).toBe(false); + }); + }); + describe('download', () => { const downloadTestFile = 'it-abort-large-download.bin'; let uploadedFileInfo: FileRecord; @@ -267,9 +364,7 @@ describe('Abort signal handling', () => { signal: controller.signal, }); - setTimeout(() => { - controller.abort(); - }, 1); + controller.abort(); await expect(listPromise).rejects.toThrow(); }); diff --git a/tests/integration/file.spec.ts b/tests/integration/file.spec.ts index 9782e95..26e5386 100644 --- a/tests/integration/file.spec.ts +++ b/tests/integration/file.spec.ts @@ -1,4 +1,4 @@ -import { type Bee, FeedIndex } from '@ethersphere/bee-js'; +import { type BatchId, type Bee, FeedIndex } from '@ethersphere/bee-js'; import path from 'path'; import { @@ -18,12 +18,16 @@ import { FileManagerEvents } from '@/utils'; import { FEED_INDEX_ZERO, ROOT_PATH } from '@/utils/constants'; describe('uploadFile', () => { + let bee: Bee; let fileManager: FileManagerBase; let drive: DriveInfo; + let ownerStamp: BatchId; const { writeTempFile, writeTempDir, cleanup } = tempFileRegistry(); beforeAll(async () => { - ({ fileManager, drive } = await setupUserDrive('upload', { stampLabel: 'uploadIntegrationStamp' })); + ({ bee, fileManager, drive, ownerStamp } = await setupUserDrive('upload', { + stampLabel: 'uploadIntegrationStamp', + })); }); afterAll(cleanup); @@ -37,6 +41,47 @@ describe('uploadFile', () => { expect(record!.version).toEqual(FEED_INDEX_ZERO.toString()); }); + it('rejects an upload onto an occupied name, and a fresh instance still reads the original bytes', async () => { + const name = 'it-upload-conflict.txt'; + const src = writeTempFile(name, 'Original Content'); + await fileManager.uploadFile(drive.id, { path: name, sourcePath: src }); + const original = fileManager.recordList.find((fr) => fr.path === name)!; + + const replacement = writeTempFile('it-upload-conflict-other.txt', 'Replacement Content'); + await expect(fileManager.uploadFile(drive.id, { path: name, sourcePath: replacement })).rejects.toThrow( + /already exists/i, + ); + + const fm2 = await retryOnPropagationDelay(async () => { + const fresh = await createInitializedFileManager(bee, ownerStamp); + const entries = await fresh.listFolder(drive.id, ROOT_PATH, ListDepth.Shallow); + if (!entries.some((e) => e.path === name)) { + throw new Error('upload not yet propagated to a fresh instance'); + } + return fresh; + }); + + const seen = fm2.recordList.find((fr) => fr.path === name)!; + expect(seen.topic.toString()).toBe(original.topic.toString()); + + const downloaded = await fm2.downloadFile(seen); + expect(Buffer.from(await streamToUint8Array(downloaded.result)).toString('utf-8')).toBe('Original Content'); + }); + + it('rejects a path with an empty leaf before uploading any content', async () => { + const src = writeTempFile('it-upload-badpath.txt', 'Should not be uploaded'); + + await expect(fileManager.uploadFile(drive.id, { path: '', sourcePath: src })).rejects.toThrow(/Invalid path/); + await expect(fileManager.uploadFile(drive.id, { path: 'nested/', sourcePath: src })).rejects.toThrow( + /Invalid path/, + ); + await expect(fileManager.uploadFile(drive.id, { path: '../escape.txt', sourcePath: src })).rejects.toThrow( + /Invalid path/, + ); + + expect(fileManager.recordList.some((fr) => fr.path.includes('escape'))).toBe(false); + }); + it('throws when uploading a directory — directories must go through uploadFiles', async () => { const dirName = 'it-upload-integration-dir'; const dirPath = writeTempDir(dirName, { 'inner.txt': 'Inner Content' }); @@ -224,6 +269,72 @@ describe('uploadFiles', () => { await expect(fileManager.uploadFiles(drive.id, [], '')).rejects.toThrow(/at least one entry/i); }); + it('defaults destinationPath to the drive root when omitted', async () => { + const src = writeTempFile('it-uploadmany-default-dest.txt', 'Default destination content'); + + const result = await fileManager.uploadFiles(drive.id, [{ path: 'defaultdest/x.txt', sourcePath: src }]); + + expect(result.failed).toHaveLength(0); + expect(result.succeeded.map((r) => r.path)).toEqual(['defaultdest/x.txt']); + + const downloads = await retryOnPropagationDelay(() => fileManager.downloadFolder(drive.id, 'defaultdest')); + const got = downloads.succeeded.find((d) => d.path === 'defaultdest/x.txt'); + expect(got).toBeDefined(); + expect(Buffer.from(await streamToUint8Array(got!.result)).toString('utf-8')).toBe('Default destination content'); + }); + + it('rejects a batch containing two entries that resolve to the same destination', async () => { + const srcFile = writeTempFile('it-uploadmany-dup-src.txt', 'dup batch content'); + + await expect( + fileManager.uploadFiles( + drive.id, + [ + { path: 'dupbatch/same.txt', sourcePath: srcFile }, + { path: 'dupbatch/same.txt', sourcePath: srcFile }, + ], + '', + ), + ).rejects.toThrow(/Duplicate destination path in batch/); + + // Rejected during planning, so nothing was created. + expect(fileManager.recordList.some((fr) => fr.path.startsWith('dupbatch/'))).toBe(false); + }); + + it('reports an occupied destination name in `failed` while the rest of the batch succeeds', async () => { + const srcFile = writeTempFile('it-uploadmany-taken-src.txt', 'Taken Content'); + const seed = await fileManager.uploadFiles(drive.id, [{ path: 'occupied/taken.txt', sourcePath: srcFile }], ''); + expect(seed.failed).toHaveLength(0); + const original = fileManager.recordList.find((fr) => fr.path === 'occupied/taken.txt')!; + + const other = writeTempFile('it-uploadmany-taken-other.txt', 'Other Content'); + const result = await fileManager.uploadFiles( + drive.id, + [ + { path: 'occupied/taken.txt', sourcePath: other }, + { path: 'occupied/fresh.txt', sourcePath: other }, + ], + '', + ); + + expect(result.failed).toHaveLength(1); + expect(result.failed[0].path).toBe('occupied/taken.txt'); + expect(result.failed[0].error).toMatch(/already exists/i); + expect(result.succeeded.map((r) => r.path)).toEqual(['occupied/fresh.txt']); + + // The occupied name still resolves to the first upload, and its bytes are unchanged. + const entries = await retryOnPropagationDelay(() => + fileManager.listFolder(drive.id, 'occupied', ListDepth.Shallow), + ); + const seen = entries.find((e) => e.path === 'occupied/taken.txt'); + expect(seen).toBeDefined(); + expect(seen!.topic.toString()).toBe(original.topic.toString()); + + const downloads = await retryOnPropagationDelay(() => fileManager.downloadFolder(drive.id, 'occupied')); + const taken = downloads.succeeded.find((d) => d.path === 'occupied/taken.txt'); + expect(Buffer.from(await streamToUint8Array(taken!.result)).toString('utf-8')).toBe('Taken Content'); + }); + it('uploads a nested folder with files and fetches them back', async () => { const rootFile = writeTempFile('it-init-nested-root.txt', 'Init nested root content'); const nestedDirPath = writeTempDir('it-init-nested-docs', { 'note.txt': 'Init nested docs content' }); @@ -432,25 +543,18 @@ describe('move', () => { let bee: Bee; let fileManager: FileManagerBase; let driveA: DriveInfo; - let driveB: DriveInfo; const { writeTempFile, writeTempDir, cleanup } = tempFileRegistry(); beforeAll(async () => { const { bee: beeDev, ownerStamp } = await ensureUniqueSignerWithStamp(); bee = beeDev; const batchIdA = await buyStampSerialized(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'moveIntegrationA'); - const batchIdB = await buyStampSerialized(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, 'moveIntegrationB'); fileManager = await createInitializedFileManager(bee, ownerStamp); await fileManager.createDrive(batchIdA, 'move-a'); const tmpDriveA = fileManager.driveList.find((d) => d.name === 'move-a'); expect(tmpDriveA).toBeDefined(); driveA = tmpDriveA!; - - await fileManager.createDrive(batchIdB, 'move-b'); - const tmpDriveB = fileManager.driveList.find((d) => d.name === 'move-b'); - expect(tmpDriveB).toBeDefined(); - driveB = tmpDriveB!; }); afterAll(cleanup); @@ -532,29 +636,6 @@ describe('move', () => { expect(Buffer.from(await streamToUint8Array(downloaded!.result)).toString('utf-8')).toBe('Inbox Note'); }); - it('moves a file across drives, updating driveId and remaining downloadable from the target', async () => { - const xFile = 'it-move-x.txt'; - const src = writeTempFile(xFile, 'Cross Drive Content'); - await fileManager.uploadFile(driveA.id, { path: xFile, sourcePath: src }); - - await fileManager.move(xFile, xFile, driveA.id, driveB.id); - - const driveAEntries = await retryOnPropagationDelay(() => fileManager.listFolder(driveA.id, '', ListDepth.Shallow)); - expect(driveAEntries.some((e) => e.path === xFile)).toBe(false); - - const driveBEntries = await retryOnPropagationDelay(() => fileManager.listFolder(driveB.id, '', ListDepth.Shallow)); - expect(driveBEntries.some((e) => e.type === NodeType.File && e.path === xFile)).toBe(true); - - const moved = fileManager.recordList.find((fr) => fr.path === xFile && fr.driveId === driveB.id.toString()); - expect(moved).toBeDefined(); - - const downloadResults = await retryOnPropagationDelay(() => fileManager.downloadFolder(driveB.id, '/')); - const downloaded = downloadResults.succeeded.find((d) => d.path === xFile); - expect(downloaded).toBeDefined(); - expect(downloadResults.failed).toEqual([]); - expect(Buffer.from(await streamToUint8Array(downloaded!.result)).toString('utf-8')).toBe('Cross Drive Content'); - }); - it('rejects invalid move calls', async () => { await expect(fileManager.move('it-move-nonexistent.txt', 'dest.txt', driveA.id)).rejects.toThrow(/not found/i); diff --git a/tests/integration/folder.spec.ts b/tests/integration/folder.spec.ts index d03ec07..11f3d92 100644 --- a/tests/integration/folder.spec.ts +++ b/tests/integration/folder.spec.ts @@ -94,7 +94,59 @@ describe('Folder operations', () => { }); }); + describe('createFolder', () => { + it('rejects a duplicate folder name and leaves exactly one folder in the listing', async () => { + const first = await fileManager.createFolder(drive.id, ROOT_PATH, 'dupfolder'); + + await expect(fileManager.createFolder(drive.id, ROOT_PATH, 'dupfolder')).rejects.toThrow(/already exists/i); + + const entries = await retryOnPropagationDelay(() => + fileManager.listFolder(drive.id, ROOT_PATH, ListDepth.Shallow), + ); + const matches = entries.filter((e) => e.type === NodeType.Folder && e.path === 'dupfolder'); + expect(matches).toHaveLength(1); + expect(matches[0].topic.toString()).toBe(first.topic.toString()); + }); + + it('rejects a folder name already taken by a file', async () => { + const srcFile = writeTempFile('it-createfolder-collide.txt', 'collide content'); + await fileManager.uploadFile(drive.id, { path: 'collide', sourcePath: srcFile }); + + await expect(fileManager.createFolder(drive.id, ROOT_PATH, 'collide')).rejects.toThrow(/already exists/i); + }); + + it('rejects a duplicate nested folder without disturbing the existing subtree', async () => { + const srcFile = writeTempFile('it-createfolder-nested.txt', 'nested content'); + const seed = await fileManager.uploadFiles(drive.id, [{ path: 'outer/inner/keep.txt', sourcePath: srcFile }], ''); + expect(seed.failed).toHaveLength(0); + + await expect(fileManager.createFolder(drive.id, 'outer', 'inner')).rejects.toThrow(/already exists/i); + + const entries = await retryOnPropagationDelay(() => + fileManager.listFolder(drive.id, 'outer/inner', ListDepth.Shallow), + ); + expect(entries.some((e) => e.type === NodeType.File && e.path === 'outer/inner/keep.txt')).toBe(true); + }); + }); + describe('downloadFolder', () => { + it('defaults to the whole drive when path is omitted', async () => { + const src = writeTempFile('it-downloadFolder-defaultpath.txt', 'default path content'); + const up = await fileManager.uploadFiles(drive.id, [{ path: 'defaultpath/deep/y.txt', sourcePath: src }], ''); + expect(up.failed).toHaveLength(0); + + const downloads = await retryOnPropagationDelay(async () => { + const res = await fileManager.downloadFolder(drive.id); + if (!res.succeeded.some((d) => d.path === 'defaultpath/deep/y.txt')) { + throw new Error('nested upload not yet propagated'); + } + return res; + }); + + const got = downloads.succeeded.find((d) => d.path === 'defaultpath/deep/y.txt'); + expect(Buffer.from(await streamToUint8Array(got!.result)).toString('utf-8')).toBe('default path content'); + }); + it('composes destinationPath with a relative item path — placement differs from destination and source', async () => { const srcFile = writeTempFile('it-downloadFolder-dest-src.txt', 'destination compose content'); diff --git a/tests/integration/init.spec.ts b/tests/integration/init.spec.ts index b02d123..e8b28ac 100644 --- a/tests/integration/init.spec.ts +++ b/tests/integration/init.spec.ts @@ -2,10 +2,12 @@ import { BatchId, Bee, BeeResponseError, + FeedIndex, type PrivateKey, type PublicKey, RedundancyLevel, Reference, + Topic, } from '@ethersphere/bee-js'; import { @@ -22,10 +24,10 @@ import { ensureUniqueSignerWithStamp } from './setup/utils'; import { FileManagerBase } from '@/fileManager'; import { type ActReferences } from '@/types'; -import { ADMIN_STAMP_LABEL, FILEMANAGER_STATE_TOPIC, FileManagerEvents, StampError } from '@/utils'; +import { FILEMANAGER_STATE_TOPIC, FileManagerEvents, StampError } from '@/utils'; import { assertActReferences } from '@/utils/asserts'; import { getFeedData } from '@/utils/bee'; -import { SWARM_ZERO_ADDRESS } from '@/utils/constants'; +import { FEED_INDEX_ZERO, SWARM_ZERO_ADDRESS } from '@/utils/constants'; import { generateRandomBytes } from '@/utils/crypto'; describe('Initialization and construction', () => { @@ -123,6 +125,21 @@ describe('Initialization and construction', () => { } }); + it('reports an empty feed for an unwritten topic, and Bee reports it as a 404', async () => { + const unwritten = new Topic(generateRandomBytes(Topic.LENGTH)); + const address = signer.publicKey().address(); + + await expect(bee.makeFeedReader(unwritten.toUint8Array(), address).downloadPayload()).rejects.toMatchObject({ + status: 404, + }); + + const { feedIndex, feedIndexNext, payload } = await getFeedData(bee, unwritten, address.toString()); + + expect(feedIndex.equals(FeedIndex.MINUS_ONE)).toBe(true); + expect(feedIndexNext.equals(FEED_INDEX_ZERO)).toBe(true); + expect(payload).toBe(SWARM_ZERO_ADDRESS); + }); + it('should not reinitialize if already initialized', async () => { const recordListBefore = [...fileManager.recordList]; fileManager.emitter.on(FileManagerEvents.INITIALIZED, (e) => { @@ -158,6 +175,41 @@ describe('Initialization and construction', () => { expect(adminStampAfter).toBeDefined(); expect(adminStampAfter?.batchID.toString()).toBe(adminStampBefore?.batchID.toString()); }); + + it('initializes against a real node even when the INITIALIZED listener throws', async () => { + const fm = new FileManagerBase(bee); + fm.emitter.on(FileManagerEvents.INITIALIZED, () => { + throw new Error('consumer handler blew up'); + }); + + await fm.initialize(); + + expect(fm.isInitialized).toBe(true); + expect(fm.driveList.some((d) => d.isAdmin)).toBe(true); + }); + + it('rolls state back after a failed initialize, so a retry against the real node succeeds', async () => { + const fm = new FileManagerBase(bee); + const events: boolean[] = []; + fm.emitter.on(FileManagerEvents.INITIALIZED, (ok: boolean) => events.push(ok)); + + const spy = jest + .spyOn(Bee.prototype, 'getNodeAddresses') + .mockRejectedValueOnce(new Error('transient node failure')); + + await fm.initialize(); + expect(events).toEqual([false]); + expect(fm.isInitialized).toBe(false); + expect(fm.driveList).toHaveLength(0); + expect(fm.recordList).toHaveLength(0); + + spy.mockRestore(); + + await fm.initialize(); + expect(events).toEqual([false, true]); + expect(fm.isInitialized).toBe(true); + expect(fm.driveList.some((d) => d.isAdmin)).toBe(true); + }); }); describe('reinitialization', () => { @@ -289,7 +341,7 @@ describe('reinitialization', () => { return batches.map((b) => ({ ...b, usable: true, - label: b.label === ADMIN_STAMP_LABEL ? 'admin' : b.label, + label: b.label, })); }); diff --git a/tests/integration/setup/utils.ts b/tests/integration/setup/utils.ts index c3bda56..c2367f1 100644 --- a/tests/integration/setup/utils.ts +++ b/tests/integration/setup/utils.ts @@ -13,7 +13,7 @@ import { import { type FileManagerBase } from '@/fileManager'; import { type DriveInfo } from '@/types'; -import { ADMIN_STAMP_LABEL } from '@/utils/constants'; +import { ADMIN_DRIVE_NAME } from '@/utils/constants'; import { generateRandomBytes } from '@/utils/crypto'; interface BeeWithStampAndSigner { @@ -32,7 +32,7 @@ export async function ensureUniqueSignerWithStamp(isNewSigner: boolean = true): if (!globalAdminStamp) { try { - globalAdminStamp = await buyStampSerialized(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, ADMIN_STAMP_LABEL); + globalAdminStamp = await buyStampSerialized(bee, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, ADMIN_DRIVE_NAME); } catch (error: any) { console.error('Failed to create/find owner stamp:', error); throw error; diff --git a/tests/integration/trash.spec.ts b/tests/integration/trash.spec.ts index b4a3773..58c6bd9 100644 --- a/tests/integration/trash.spec.ts +++ b/tests/integration/trash.spec.ts @@ -6,7 +6,7 @@ import { setupUserDrive, tempFileRegistry } from './setup/utils'; import { FileManagerBase } from '@/fileManager'; import { type DriveInfo, type FileRecord, ListDepth, NodeStatus, NodeType } from '@/types'; -import { ROOT_PATH } from '@/utils/constants'; +import { ROOT_PATH, TRASH_FOLDER_NAME } from '@/utils/constants'; describe('Lifecycle management', () => { let bee: Bee; @@ -36,63 +36,85 @@ describe('Lifecycle management', () => { afterAll(cleanup); - it('should trash a file (soft-delete)', async () => { + const freshInstance = async (): Promise => await createInitializedFileManager(bee, adminBatch); + + it('trashes a file: a fresh instance stops listing it and finds it in the trash instead', async () => { const initial = fileManager.recordList.find((fr) => fr.path === TEST_NAME)!; const beforeVersion = BigInt((initial.version ?? '0').toString()); - await fileManager.trashFile(initial); + await fileManager.trash(drive.id, TEST_NAME); + expect(initial.status).toBe(NodeStatus.Trashed); + expect(initial.path).toBe(`${TRASH_FOLDER_NAME}/${initial.topic}`); + expect(initial.trashedFrom).toBe(TEST_NAME); - const fm2 = await createInitializedFileManager(bee, adminBatch); - await fm2.listFolder(new Identifier(drive.id), ROOT_PATH); + const fm2 = await freshInstance(); + const listed = await fm2.listFolder(new Identifier(drive.id), ROOT_PATH, ListDepth.Deep); - const fi2 = fm2.recordList.find((fr) => fr.path === TEST_NAME)!; + expect(listed.some((e) => e.path === TEST_NAME)).toBe(false); + expect(listed.some((e) => e.path === TRASH_FOLDER_NAME)).toBe(false); - expect(fi2.status).toBe(NodeStatus.Trashed); - expect(BigInt(fi2.version!.toString())).toBe(beforeVersion); + const trashed = await fm2.listTrash(drive.id); + const found = trashed.find((e) => e.topic === initial.topic)!; + + expect(found).toBeDefined(); + expect(found.status).toBe(NodeStatus.Trashed); + expect(found.trashedFrom).toBe(TEST_NAME); + expect(BigInt(found.version!.toString())).toBe(beforeVersion); }); - it('should recover a previously trashed file', async () => { - if (testFi.status !== NodeStatus.Trashed) { - await fileManager.trashFile(testFi); - expect(testFi.status).toBe(NodeStatus.Trashed); - } else { - expect(testFi.status).toBe(NodeStatus.Trashed); - } + it('recovers the file back to its origin without touching its version', async () => { const beforeVersion = BigInt(testFi.version!.toString()); - await fileManager.recoverFile(testFi); + const restoredPath = await fileManager.recover(drive.id, `${TRASH_FOLDER_NAME}/${testFi.topic}`); + + expect(restoredPath).toBe(TEST_NAME); + expect(testFi.status).toBe(NodeStatus.Active); + expect(testFi.path).toBe(TEST_NAME); const fi2 = await retryOnPropagationDelay(async () => { - const fm2 = await createInitializedFileManager(bee, adminBatch); + const fm2 = await freshInstance(); await fm2.listFolder(drive.id, ROOT_PATH); - const found = fm2.recordList.find((fr) => fr.path === TEST_NAME)!; - if (found.status !== NodeStatus.Active) { + const recovered = fm2.recordList.find((fr) => fr.path === TEST_NAME); + if (!recovered) { throw new Error('recover not yet propagated to a fresh instance'); } - return found; + return recovered; }); expect(fi2.status).toBe(NodeStatus.Active); expect(BigInt(fi2.version!.toString())).toBe(beforeVersion); }); - it('should recover a previously trashed folder', async () => { + it('trashes a folder and its subtree in two manifest writes, and recovers it whole', async () => { const FOLDER_NAME = 'trash-recover-folder'; + const src = writeTempFile('folder-child.txt', 'child content'); const folder = await fileManager.createFolder(drive.id, ROOT_PATH, FOLDER_NAME); - expect(folder.status).toBe(NodeStatus.Active); + await fileManager.uploadFile(drive.id, { path: `${FOLDER_NAME}/child.txt`, sourcePath: src }); + + await fileManager.trash(drive.id, FOLDER_NAME); + + const trashedRoots = await retryOnPropagationDelay(async () => { + const fm2 = await freshInstance(); + const entries = await fm2.listFolder(drive.id, ROOT_PATH, ListDepth.Deep); + if (entries.some((e) => e.path.startsWith(FOLDER_NAME))) { + throw new Error('folder trash not yet propagated to a fresh instance'); + } + return await fm2.listTrash(drive.id, ListDepth.Deep); + }); - await fileManager.trashFolder(folder); - expect(folder.status).toBe(NodeStatus.Trashed); + const child = trashedRoots.find((e) => e.path === `${TRASH_FOLDER_NAME}/${folder.topic}/child.txt`)!; + expect(child).toBeDefined(); + expect(child.trashedFrom).toBe(`${FOLDER_NAME}/child.txt`); - await fileManager.recoverFolder(folder); - expect(folder.status).toBe(NodeStatus.Active); + const restoredPath = await fileManager.recover(drive.id, `${TRASH_FOLDER_NAME}/${folder.topic}`); + expect(restoredPath).toBe(FOLDER_NAME); const recovered = await retryOnPropagationDelay(async () => { - const fm2 = await createInitializedFileManager(bee, adminBatch); - const entries = await fm2.listFolder(drive.id, ROOT_PATH); - const found = entries.find((e) => e.type === NodeType.Folder && e.topic.toString() === folder.topic.toString()); - if (!found || found.status !== NodeStatus.Active) { + const fm2 = await freshInstance(); + const entries = await fm2.listFolder(drive.id, ROOT_PATH, ListDepth.Deep); + const found = entries.find((e) => e.path === `${FOLDER_NAME}/child.txt`); + if (!found) { throw new Error('folder recover not yet propagated to a fresh instance'); } return found; @@ -101,53 +123,113 @@ describe('Lifecycle management', () => { expect(recovered.status).toBe(NodeStatus.Active); }); - it('should forget (hard-delete) a file', async () => { - await fileManager.forget(drive.id, TEST_NAME); - expect(fileManager.recordList.find((fr) => fr.path === TEST_NAME)).toBeUndefined(); + it('keeps two same-named files apart in the trash and restores each to its own folder', async () => { + const src = writeTempFile('it-trash-dup.txt', 'dup content'); + const up = await fileManager.uploadFiles( + drive.id, + [ + { path: 'ta/dup.txt', sourcePath: src }, + { path: 'tb/dup.txt', sourcePath: src }, + ], + '', + ); + expect(up.failed).toHaveLength(0); - const fm2 = new FileManagerBase(bee); - await fm2.initialize(); + const inA = fileManager.recordList.find((fr) => fr.path === 'ta/dup.txt')!; + const inB = fileManager.recordList.find((fr) => fr.path === 'tb/dup.txt')!; - expect(fm2.recordList.find((fr) => fr.path === TEST_NAME)).toBeUndefined(); + await fileManager.trash(drive.id, 'ta/dup.txt'); + await fileManager.trash(drive.id, 'tb/dup.txt'); + + const trashed = await retryOnPropagationDelay(async () => { + const fm2 = await freshInstance(); + const entries = await fm2.listTrash(drive.id); + if (entries.filter((e) => e.trashedFrom?.endsWith('dup.txt')).length !== 2) { + throw new Error('both trashed nodes not yet propagated to a fresh instance'); + } + return entries; + }); + + expect(trashed.find((e) => e.topic === inA.topic)!.trashedFrom).toBe('ta/dup.txt'); + expect(trashed.find((e) => e.topic === inB.topic)!.trashedFrom).toBe('tb/dup.txt'); + + expect(await fileManager.recover(drive.id, `${TRASH_FOLDER_NAME}/${inA.topic}`)).toBe('ta/dup.txt'); + expect(await fileManager.recover(drive.id, `${TRASH_FOLDER_NAME}/${inB.topic}`)).toBe('tb/dup.txt'); }); - it('should never duplicate FileRecord entries when trashing/recovering', async () => { - await fileManager.uploadFile(drive.id, { path: TEST_NAME, sourcePath: testSrc }); + it('recovers to an explicit destination when the origin folder is gone', async () => { + const src = writeTempFile('it-orphan.txt', 'orphan content'); + await fileManager.uploadFiles(drive.id, [{ path: 'doomed/orphan.txt', sourcePath: src }], ''); + + const record = fileManager.recordList.find((fr) => fr.path === 'doomed/orphan.txt')!; + await fileManager.trash(drive.id, 'doomed/orphan.txt'); + await fileManager.forget(drive.id, 'doomed'); - const freshFi = fileManager.recordList.find((fr) => fr.path === TEST_NAME)!; - const topic = freshFi.topic.toString(); - expect(fileManager.recordList.filter((fr) => fr.topic.toString() === topic)).toHaveLength(1); + const trashedPath = `${TRASH_FOLDER_NAME}/${record.topic}`; + await expect(fileManager.recover(drive.id, trashedPath)).rejects.toThrow(/Path not found/); - await fileManager.trashFile(freshFi); - expect(freshFi.status).toBe(NodeStatus.Trashed); + expect(await fileManager.recover(drive.id, trashedPath, 'orphan.txt')).toBe('orphan.txt'); - await expect(fileManager.trashFile(freshFi)).rejects.toThrow(/Already trashed/i); + const listed = await retryOnPropagationDelay(async () => { + const fm2 = await freshInstance(); + const entries = await fm2.listFolder(drive.id, ROOT_PATH); + if (!entries.some((e) => e.path === 'orphan.txt')) { + throw new Error('explicit recover not yet propagated to a fresh instance'); + } + return entries; + }); - await fileManager.recoverFile(freshFi); - expect(freshFi.status).toBe(NodeStatus.Active); + expect(listed.some((e) => e.path === 'orphan.txt')).toBe(true); + }); - await expect(fileManager.recoverFile(freshFi)).rejects.toThrow(/Not trashed, cannot recover/i); + it('refuses to update a trashed file, and refuses the trash folder as a write destination', async () => { + const src = writeTempFile('it-guarded.txt', 'guarded content'); + await fileManager.uploadFile(drive.id, { path: 'guarded.txt', sourcePath: src }); + const record = fileManager.recordList.find((fr) => fr.path === 'guarded.txt')!; - expect(fileManager.recordList.filter((fr) => fr.topic.toString() === topic)).toHaveLength(1); + await fileManager.trash(drive.id, 'guarded.txt'); + + await expect(fileManager.updateFile(drive.id, record, { customMetadata: { a: 'b' } })).rejects.toThrow( + /Cannot update a trashed file/, + ); + await expect( + fileManager.uploadFile(drive.id, { path: `${TRASH_FOLDER_NAME}/sneaky.txt`, sourcePath: src }), + ).rejects.toThrow(/reserved/); + await expect(fileManager.createFolder(drive.id, ROOT_PATH, TRASH_FOLDER_NAME)).rejects.toThrow(/reserved/); + await expect(fileManager.move('guarded.txt', `${TRASH_FOLDER_NAME}/x.txt`, drive.id)).rejects.toThrow(/reserved/); }); - it('recordList should never gain duplicate topics when trash/restoring', async () => { - await fileManager.listFolder(drive.id, ROOT_PATH); + it('empties the trash in one write and leaves the active tree untouched', async () => { + const src = writeTempFile('it-empty.txt', 'empty me'); + await fileManager.uploadFile(drive.id, { path: 'keep.txt', sourcePath: src }); + await fileManager.uploadFile(drive.id, { path: 'discard.txt', sourcePath: src }); + await fileManager.trash(drive.id, 'discard.txt'); + + const count = await fileManager.emptyTrash(drive.id); + expect(count).toBeGreaterThan(0); + expect(await fileManager.listTrash(drive.id)).toEqual([]); + + const listed = await retryOnPropagationDelay(async () => { + const fm2 = await freshInstance(); + const entries = await fm2.listTrash(drive.id); + if (entries.length > 0) { + throw new Error('emptied trash not yet propagated to a fresh instance'); + } + return await fm2.listFolder(drive.id, ROOT_PATH); + }); - const fi0 = fileManager.recordList.find((fr) => fr.path === TEST_NAME)!; - const topic = fi0.topic.toString(); - const beforeVer = BigInt(fi0.version!.toString()); + expect(listed.some((e) => e.path === 'keep.txt')).toBe(true); + expect(listed.some((e) => e.path === 'discard.txt')).toBe(false); + }); - if (fi0.status !== NodeStatus.Trashed) { - await fileManager.trashFile(fi0); - } - await fileManager.recoverFile(fi0); + it('should forget (hard-delete) a file', async () => { + await fileManager.forget(drive.id, TEST_NAME); + expect(fileManager.recordList.find((fr) => fr.path === TEST_NAME)).toBeUndefined(); - const fm2 = await createInitializedFileManager(bee, adminBatch); - await fm2.listFolder(drive.id, ROOT_PATH); - const fi2 = fm2.recordList.find((fr) => fr.topic.toString() === topic)!; + const fm2 = new FileManagerBase(bee); + await fm2.initialize(); - expect(BigInt(fi2.version!.toString())).toBe(beforeVer); + expect(fm2.recordList.find((fr) => fr.path === TEST_NAME)).toBeUndefined(); }); it('forgets only the targeted file, leaving the same-named file in the other folder', async () => { diff --git a/tests/integration/version.spec.ts b/tests/integration/version.spec.ts index 2b64ab9..5b645cc 100644 --- a/tests/integration/version.spec.ts +++ b/tests/integration/version.spec.ts @@ -1,7 +1,8 @@ -import { type Bee, FeedIndex, type PrivateKey, Topic } from '@ethersphere/bee-js'; +import { type BatchId, type Bee, FeedIndex, type PrivateKey, Topic } from '@ethersphere/bee-js'; import { buyStampSerialized, + createInitializedFileManager, DEFAULT_BATCH_AMOUNT, DEFAULT_BATCH_DEPTH, retryOnPropagationDelay, @@ -11,7 +12,7 @@ import { import { setupUserDrive, tempFileRegistry } from './setup/utils'; import { FileManagerBase } from '@/fileManager'; -import { type DriveInfo, type FileRecord } from '@/types'; +import { type DriveInfo, type FileRecord, ListDepth } from '@/types'; import { getFeedData } from '@/utils/bee'; import { FEED_INDEX_ZERO, ROOT_PATH } from '@/utils/constants'; @@ -20,6 +21,7 @@ describe('Version control', () => { let fileManager: FileManagerBase; let drive: DriveInfo; let signer: PrivateKey; + let ownerStamp: BatchId; const { writeTempFile, cleanup } = tempFileRegistry(); // helper to ensure at least one base FileRecord exists. @@ -34,7 +36,9 @@ describe('Version control', () => { }; beforeAll(async () => { - ({ bee, fileManager, drive, signer } = await setupUserDrive('versioncontrol', { stampLabel: 'versioningStamp' })); + ({ bee, fileManager, drive, signer, ownerStamp } = await setupUserDrive('versioncontrol', { + stampLabel: 'versioningStamp', + })); }); afterAll(cleanup); @@ -302,4 +306,70 @@ describe('Version control', () => { expect(downloadResults.failed).toEqual([]); expect(Buffer.from(await streamToUint8Array(downloaded!.result)).toString('utf-8')).toBe('Restore Move V0 Content'); }); + + it('restores an old version from a cold instance without disturbing a same-named file at the root', async () => { + const NAME = `cold-restore-${Date.now()}.txt`; + const src = writeTempFile(NAME, 'Cold V0'); + await fileManager.uploadFile(drive.id, { path: NAME, sourcePath: src }); + const base = fileManager.recordList.find((f) => f.path === NAME)!; + const topic = base.topic.toString(); + + writeTempFile(NAME, 'Cold V1'); + await fileManager.updateFile(drive.id, base, { item: { sourcePath: src } }); + + await fileManager.createFolder(drive.id, ROOT_PATH, 'coldsub'); + const destPath = 'coldsub/moved.txt'; + await fileManager.move(NAME, destPath, drive.id); + + // A decoy now occupies the leaf name that the old version's payload still records. + const decoySrc = writeTempFile(`decoy-${NAME}`, 'Decoy Content'); + await fileManager.uploadFile(drive.id, { path: NAME, sourcePath: decoySrc }); + const decoy = fileManager.recordList.find((f) => f.path === NAME)!; + expect(decoy.topic.toString()).not.toBe(topic); + + const movedRecord = await retryOnPropagationDelay(async () => { + const reader = await createInitializedFileManager(bee, ownerStamp); + const entries = await reader.listFolder(drive.id, 'coldsub', ListDepth.Shallow); + const found = entries.find((e) => e.path === destPath); + if (!found) { + throw new Error('move not yet propagated to a fresh instance'); + } + return found as FileRecord; + }); + + // A cold instance: it never listed the drive, so nothing hydrates the record's absolute path + // except the record the caller hands in. + const coldFm = await createInitializedFileManager(bee, ownerStamp); + expect(coldFm.recordList.find((f) => f.topic.toString() === topic)).toBeUndefined(); + + const v0 = await coldFm.getFileVersion(movedRecord, FEED_INDEX_ZERO); + expect(v0.version).toBe(FEED_INDEX_ZERO.toString()); + expect(v0.path).toBe(destPath); + + await coldFm.restoreFileVersion(v0); + + // The restore landed on the moved file. + const restoredContent = await retryOnPropagationDelay(async () => { + const downloads = await coldFm.downloadFolder(drive.id, 'coldsub'); + const got = downloads.succeeded.find((d) => d.path === destPath); + if (!got) { + throw new Error('restored file not yet downloadable'); + } + return Buffer.from(await streamToUint8Array(got.result)).toString('utf-8'); + }); + expect(restoredContent).toBe('Cold V0'); + + // The decoy sharing the leaf name kept its own topic, version and bytes. + const verifier = await createInitializedFileManager(bee, ownerStamp); + const rootEntries = await retryOnPropagationDelay(() => + verifier.listFolder(drive.id, ROOT_PATH, ListDepth.Shallow), + ); + const decoySeen = rootEntries.find((e) => e.path === NAME); + expect(decoySeen).toBeDefined(); + expect(decoySeen!.topic.toString()).toBe(decoy.topic.toString()); + expect(decoySeen!.version).toBe(decoy.version); + + const decoyDownload = await verifier.downloadFile(decoySeen as FileRecord); + expect(Buffer.from(await streamToUint8Array(decoyDownload.result)).toString('utf-8')).toBe('Decoy Content'); + }); }); diff --git a/tests/unit/abort.spec.ts b/tests/unit/abort.spec.ts index dc1bc07..d7d546a 100644 --- a/tests/unit/abort.spec.ts +++ b/tests/unit/abort.spec.ts @@ -1,6 +1,8 @@ -import { BatchId, Bee, MantarayNode, RedundancyLevel, Topic } from '@ethersphere/bee-js'; +import { BatchId, Bee, type BeeRequestOptions, MantarayNode, RedundancyLevel, Topic } from '@ethersphere/bee-js'; import { + abortAfterFirstRecordWrite, + BEE_URL, createInitializedFileManager, DEFAULT_MOCK_SIGNER, DUMMY_BATCH_ID, @@ -10,7 +12,9 @@ import { import { applyDefaultMocks, createMockDriveInfo, createMockNodeAddresses, seedRecords } from './mock'; -import { type FileRecord, ListDepth, NodeType } from '@/types'; +import { EventEmitterBase } from '@/eventEmitter'; +import { FileManagerBase } from '@/fileManager'; +import { type DriveInfo, type FileRecord, ListDepth, NodeType } from '@/types'; import { DriveError } from '@/utils'; import { SWARM_ZERO_ADDRESS } from '@/utils/constants'; @@ -87,6 +91,121 @@ describe('Abort signal handling', () => { ).resolves.not.toThrow(); }); + describe('uploadFiles', () => { + async function sequentialFm(): Promise { + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + const fm = new FileManagerBase(bee, new EventEmitterBase(), { uploadConcurrency: 1 }); + await fm.initialize(); + await fm.createAdminDrive(DUMMY_BATCH_ID, RedundancyLevel.MEDIUM); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + return fm; + } + + it('rejects immediately when the signal is already aborted', async () => { + const fm = await sequentialFm(); + const di = fm.driveList[1]; + + const controller = new AbortController(); + controller.abort(); + + const uploadDataSpy = jest.spyOn(Bee.prototype, 'uploadData'); + uploadDataSpy.mockClear(); + + await expect( + fm.uploadFiles(di.id, [{ path: 'pre-aborted.txt', ...makeUploadSource('package.json') }], '', undefined, { + signal: controller.signal, + }), + ).rejects.toThrow(); + + expect(uploadDataSpy).not.toHaveBeenCalled(); + }); + + it('stops paying for the rest of the batch as soon as the signal fires', async () => { + const fm = await sequentialFm(); + const di = fm.driveList[1]; + + const controller = new AbortController(); + abortAfterFirstRecordWrite(fm, controller); + + const uploadDataSpy = jest.spyOn(Bee.prototype, 'uploadData'); + uploadDataSpy.mockClear(); + + await expect( + fm.uploadFiles( + di.id, + [ + { path: 'first.txt', ...makeUploadSource('package.json') }, + { path: 'never-one.txt', ...makeUploadSource('package.json') }, + { path: 'never-two.txt', ...makeUploadSource('package.json') }, + ], + '', + undefined, + { signal: controller.signal }, + ), + ).rejects.toThrow(); + + const uploadedPayloads = uploadDataSpy.mock.calls.length; + expect(uploadedPayloads).toBeGreaterThan(0); + expect(uploadedPayloads).toBeLessThanOrEqual(2); + }); + + it('discards the aborted batch from local state instead of leaving it half-applied', async () => { + const fm = await sequentialFm(); + const di = fm.driveList[1]; + + const controller = new AbortController(); + abortAfterFirstRecordWrite(fm, controller); + + const recordsBefore = fm.recordList.length; + const manifestRefBefore = { ...di.manifestRef } as Required['manifestRef']; + + await expect( + fm.uploadFiles( + di.id, + [ + { path: 'aborted-one.txt', ...makeUploadSource('package.json') }, + { path: 'aborted-two.txt', ...makeUploadSource('package.json') }, + ], + '', + undefined, + { signal: controller.signal }, + ), + ).rejects.toThrow(); + + expect(fm.recordList).toHaveLength(recordsBefore); + expect(fm.recordList.some((fr) => fr.path.startsWith('aborted-'))).toBe(false); + + expect((fm as any).store.getManifestCache(di.topic)).toBeUndefined(); + + expect(fm.driveList[1].manifestRef).toEqual(manifestRefBefore); + }); + + it('saves the batch normally when the signal never fires', async () => { + const fm = await sequentialFm(); + const di = fm.driveList[1]; + + const controller = new AbortController(); + const saveManifestSpy = jest.spyOn((fm as any).store, 'saveMantarayNode'); + + const result = await fm.uploadFiles( + di.id, + [{ path: 'clean.txt', ...makeUploadSource('package.json') }], + '', + undefined, + { signal: controller.signal }, + ); + + expect(result.failed).toHaveLength(0); + expect(result.succeeded.map((r) => r.path)).toEqual(['clean.txt']); + expect(saveManifestSpy).toHaveBeenCalledTimes(1); + const finalizeOptions = saveManifestSpy.mock.calls[0][2] as BeeRequestOptions | undefined; + expect(finalizeOptions?.signal).toBe(controller.signal); + + const driveMantaray = (fm as any).store.getManifestCache(di.topic) as MantarayNode; + expect(driveMantaray.find('clean.txt')).toBeTruthy(); + }); + }); + it('throw if listFolder is called on a non-existent drive', async () => { const fm = await createInitializedFileManager(); const freshDrive = createMockDriveInfo(actPublisher); diff --git a/tests/unit/drive.spec.ts b/tests/unit/drive.spec.ts index 9499e93..686df66 100644 --- a/tests/unit/drive.spec.ts +++ b/tests/unit/drive.spec.ts @@ -6,7 +6,7 @@ import { applyDefaultMocks, createMockDriveInfo, createMockNodeAddresses, seedRe import { type DriveInfo, NodeType } from '@/types'; import { DriveError, FileManagerEvents } from '@/utils'; -import { ADMIN_STAMP_LABEL, SWARM_ZERO_ADDRESS } from '@/utils/constants'; +import { ADMIN_DRIVE_NAME, SWARM_ZERO_ADDRESS } from '@/utils/constants'; describe('Drive operations', () => { const otherMockBatchId = new BatchId('4'.repeat(64)); @@ -22,7 +22,7 @@ describe('Drive operations', () => { const fm = await createInitializedFileManager(); const di = fm.driveList[0]; expect(di).toBeDefined(); - expect(di.name).toBe(ADMIN_STAMP_LABEL); + expect(di.name).toBe(ADMIN_DRIVE_NAME); expect(di.batchId).toBe(DUMMY_BATCH_ID.toString()); expect(di.id).toHaveLength(64); expect(di.owner).toBe(owner); diff --git a/tests/unit/events.spec.ts b/tests/unit/events.spec.ts index fb0e48b..b7ee1fe 100644 --- a/tests/unit/events.spec.ts +++ b/tests/unit/events.spec.ts @@ -5,6 +5,7 @@ import { BEE_URL, createInitializedFileManager, DEFAULT_MOCK_SIGNER, DUMMY_BATCH import { applyDefaultMocks } from './mock'; import { EventEmitterBase } from '@/eventEmitter'; +import { FileManagerBase } from '@/fileManager'; import { NodeStatus } from '@/types'; import { FileManagerEvents } from '@/utils'; @@ -59,4 +60,54 @@ describe('Events and emitter', () => { expect(eventHandler).toHaveBeenCalledWith(true); }); + + // Emit sites sit mid-method, so a consumer's throwing handler must not become a library failure. + describe('listener isolation', () => { + it('keeps delivering to the remaining listeners when one throws', () => { + const emitter = new EventEmitterBase(); + const before = jest.fn(); + const after = jest.fn(); + + emitter.on('some-event', before); + emitter.on('some-event', () => { + throw new Error('listener blew up'); + }); + emitter.on('some-event', after); + + expect(() => emitter.emit('some-event', 'payload')).not.toThrow(); + expect(before).toHaveBeenCalledWith('payload'); + expect(after).toHaveBeenCalledWith('payload'); + }); + + it('initializes successfully even when an INITIALIZED listener throws', async () => { + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + const emitter = new EventEmitterBase(); + emitter.on(FileManagerEvents.INITIALIZED, () => { + throw new Error('consumer handler blew up'); + }); + + const fm = new FileManagerBase(bee, emitter); + await fm.initialize(); + + expect(fm.isInitialized).toBe(true); + }); + + it('completes an upload even when the FILE_UPLOADED listener throws', async () => { + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + const emitter = new EventEmitterBase(); + + const fm = await createInitializedFileManager(bee, DUMMY_BATCH_ID, emitter); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + fm.emitter.on(FileManagerEvents.FILE_UPLOADED, () => { + throw new Error('consumer handler blew up'); + }); + + const record = await fm.uploadFile(di.id, { path: 'package.json', ...makeUploadSource('package.json') }); + + expect(record.path).toBe('package.json'); + expect(fm.recordList.filter((fr) => fr.path === 'package.json')).toHaveLength(1); + }); + }); }); diff --git a/tests/unit/file.spec.ts b/tests/unit/file.spec.ts index 1a03552..596dd45 100644 --- a/tests/unit/file.spec.ts +++ b/tests/unit/file.spec.ts @@ -35,7 +35,9 @@ import { MANIFEST_METADATA_FILE_TOPIC, MANIFEST_METADATA_NODE_TOPIC, MANIFEST_METADATA_NODE_TYPE, + ROOT_PATH, SWARM_ZERO_ADDRESS, + TRASH_FOLDER_NAME, } from '@/utils/constants'; describe('File operations', () => { @@ -254,6 +256,141 @@ describe('File operations', () => { fm.uploadFile(ghost.id, { path: 'package.json', ...makeUploadSource('package.json') }), ).rejects.toThrow(`Drive with id ${ghost.id.slice(0, 6)} not found`); }); + + it('rejects a second upload onto an occupied name and leaves the original fork intact', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + await fm.uploadFile(di.id, { path: 'package.json', ...makeUploadSource('package.json') }); + const first = fm.recordList.find((fr) => fr.path === 'package.json')!; + + await expect(fm.uploadFile(di.id, { path: 'package.json', ...makeUploadSource('package.json') })).rejects.toThrow( + /Node already exists at "package.json"/, + ); + + const driveMantaray = (fm as any).store.getManifestCache(di.topic) as MantarayNode; + expect(driveMantaray.find('package.json')?.metadata?.[MANIFEST_METADATA_NODE_TOPIC]).toBe(first.topic); + expect(fm.recordList.filter((fr) => fr.path === 'package.json')).toHaveLength(1); + }); + + it('rejects an occupied name before spending a stamp on content or a feed slot', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + await fm.uploadFile(di.id, { path: 'package.json', ...makeUploadSource('package.json') }); + + const uploadDataSpy = jest.spyOn(Bee.prototype, 'uploadData'); + uploadDataSpy.mockClear(); + + await expect(fm.uploadFile(di.id, { path: 'package.json', ...makeUploadSource('package.json') })).rejects.toThrow( + /already exists/, + ); + + expect(uploadDataSpy).not.toHaveBeenCalled(); + }); + + // An empty leaf yields addFork(''), which mantaray ignores entirely — the upload would vanish. + it.each(['', '/', 'docs/', '..', 'docs/../escape.txt'])( + 'rejects the invalid path %p before uploading anything', + async (badPath) => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + const uploadDataSpy = jest.spyOn(Bee.prototype, 'uploadData'); + uploadDataSpy.mockClear(); + + await expect(fm.uploadFile(di.id, { path: badPath, ...makeUploadSource('package.json') })).rejects.toThrow( + FileRecordError, + ); + + expect(uploadDataSpy).not.toHaveBeenCalled(); + expect(fm.recordList).toHaveLength(0); + }, + ); + }); + + describe('uploadFiles', () => { + it('defaults destinationPath to the drive root when omitted', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + const result = await fm.uploadFiles(di.id, [{ path: 'root-default.txt', ...makeUploadSource('package.json') }]); + + expect(result.failed).toHaveLength(0); + expect(result.succeeded.map((r) => r.path)).toEqual(['root-default.txt']); + + const driveMantaray = (fm as any).store.getManifestCache(di.topic) as MantarayNode; + expect(driveMantaray.find('root-default.txt')).toBeTruthy(); + }); + + it('treats an omitted destinationPath the same as an explicit root', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + const omitted = await fm.uploadFiles(di.id, [{ path: 'omitted.txt', ...makeUploadSource('package.json') }]); + const explicit = await fm.uploadFiles( + di.id, + [{ path: 'explicit.txt', ...makeUploadSource('package.json') }], + ROOT_PATH, + ); + + expect(omitted.succeeded[0].path).toBe('omitted.txt'); + expect(explicit.succeeded[0].path).toBe('explicit.txt'); + }); + + it('rejects a batch whose entries resolve to the same destination path', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + const uploadDataSpy = jest.spyOn(Bee.prototype, 'uploadData'); + uploadDataSpy.mockClear(); + + await expect( + fm.uploadFiles( + di.id, + [ + { path: 'docs/a.txt', ...makeUploadSource('package.json') }, + { path: 'docs/a.txt', ...makeUploadSource('package.json') }, + ], + '', + ), + ).rejects.toThrow(/Duplicate destination path in batch: "docs\/a.txt"/); + + expect(uploadDataSpy).not.toHaveBeenCalled(); + }); + + it('collects an occupied destination name in `failed` and still uploads the rest', async () => { + const fm = await createInitializedFileManager(); + await fm.createDrive(otherMockBatchId, 'Test Drive'); + const di = fm.driveList[1]; + + await fm.uploadFile(di.id, { path: 'taken.txt', ...makeUploadSource('package.json') }); + const original = fm.recordList.find((fr) => fr.path === 'taken.txt')!; + + const result = await fm.uploadFiles( + di.id, + [ + { path: 'taken.txt', ...makeUploadSource('package.json') }, + { path: 'fresh.txt', ...makeUploadSource('package.json') }, + ], + '', + ); + + expect(result.failed).toHaveLength(1); + expect(result.failed[0].path).toBe('taken.txt'); + expect(result.failed[0].error).toMatch(/already exists/); + expect(result.succeeded.map((r) => r.path)).toEqual(['fresh.txt']); + + // The occupied fork still points at the original node. + const driveMantaray = (fm as any).store.getManifestCache(di.topic) as MantarayNode; + expect(driveMantaray.find('taken.txt')?.metadata?.[MANIFEST_METADATA_NODE_TOPIC]).toBe(original.topic); + }); }); describe('updateFile', () => { @@ -378,7 +515,13 @@ describe('File operations', () => { await fm.updateFile(di.id, record, { customMetadata: { note: 'hi' } }); - expect(getRecordSpy).toHaveBeenCalledWith(record.topic, record.actPublisher, expect.anything(), undefined); + expect(getRecordSpy).toHaveBeenCalledWith( + record.topic, + record.actPublisher, + expect.anything(), + { isHeadRead: true }, + undefined, + ); const rehydrated = fm.recordList.filter((f) => f.topic === record.topic); expect(rehydrated).toHaveLength(1); expect(rehydrated[0].version).toBe(FeedIndex.fromBigInt(1n).toString()); @@ -463,22 +606,21 @@ describe('File operations', () => { expect(driveMantaray.find('renamed.json')).toBeTruthy(); }); - it('refuses to move a trashed node until it is recovered', async () => { + it('cannot reach a trashed node, and refuses the trash folder as an endpoint', async () => { const fm = await createInitializedFileManager(); await fm.createDrive(otherMockBatchId, 'Test Drive'); const drive = fm.driveList[1]; await fm.uploadFile(drive.id, { path: 'package.json', ...makeUploadSource('package.json') }); const original = fm.recordList.find((fr) => fr.path === 'package.json')!; - await fm.trashFile(original); + await fm.trash(drive.id, 'package.json'); - await expect(fm.move('package.json', 'renamed.json', drive.id)).rejects.toThrow( - 'Cannot move a trashed file/folder; recover it first', + await expect(fm.move('package.json', 'renamed.json', drive.id)).rejects.toThrow('Path not found: package.json'); + await expect(fm.move(`${TRASH_FOLDER_NAME}/${original.topic}`, 'renamed.json', drive.id)).rejects.toThrow( + /reserved/, ); - // The guard fires before any manifest mutation — the fork stays put. const driveMantaray = (fm as any).store.getManifestCache(drive.topic) as MantarayNode; - expect(driveMantaray.find('package.json')).toBeTruthy(); expect(driveMantaray.find('renamed.json')).toBeFalsy(); }); diff --git a/tests/unit/folder.spec.ts b/tests/unit/folder.spec.ts index 4174c68..10265a8 100644 --- a/tests/unit/folder.spec.ts +++ b/tests/unit/folder.spec.ts @@ -1,25 +1,15 @@ -import { - BatchId, - Bee, - Bytes, - FeedIndex, - Identifier, - type MantarayNode, - RedundancyLevel, - Topic, -} from '@ethersphere/bee-js'; - -import { createInitializedFileManager, DEFAULT_MOCK_SIGNER, DUMMY_BATCH_ID } from '../utils'; +import { Bee, Bytes, FeedIndex, Identifier, type MantarayNode, RedundancyLevel, Topic } from '@ethersphere/bee-js'; + +import { createInitializedFileManager, DEFAULT_MOCK_SIGNER, DUMMY_BATCH_ID, makeUploadSource } from '../utils'; import { applyDefaultMocks, createMockDriveInfo, createMockNodeAddresses, seedDummyFile, seedRecords } from './mock'; import { ListDepth, type NodeHeader, NodeType } from '@/types'; import { FileManagerEvents } from '@/utils'; import { getFeedData } from '@/utils/bee'; -import { SWARM_ZERO_ADDRESS } from '@/utils/constants'; +import { MANIFEST_METADATA_NODE_TOPIC, SWARM_ZERO_ADDRESS } from '@/utils/constants'; describe('Folder operations', () => { - const otherMockBatchId = new BatchId('4'.repeat(64)); const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); const actPublisher = createMockNodeAddresses().publicKey.toCompressedHex(); @@ -56,6 +46,21 @@ describe('Folder operations', () => { expect(results.succeeded.map((r) => r.path).sort()).toEqual(['a.txt', 'b.txt']); }); + it('defaults to the whole drive when path is omitted', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + seedRecords( + fm, + seedDummyFile(drive, 'a.txt', '1'.repeat(64), owner, actPublisher), + seedDummyFile(drive, 'nested/b.txt', '2'.repeat(64), owner, actPublisher), + ); + + const results = await fm.downloadFolder(drive.id); + + expect(results.failed).toEqual([]); + expect(results.succeeded.map((r) => r.path).sort()).toEqual(['a.txt', 'nested/b.txt']); + }); + it('downloadFolder does not download files belonging to a different drive', async () => { const fm = await createInitializedFileManager(); const drive = fm.driveList[0]; @@ -231,40 +236,75 @@ describe('Folder operations', () => { await expect(fm.createFolder(drive.id, '', 'a/b')).rejects.toThrow('Invalid folder name'); }); - }); - describe('move', () => { - it('refreshes trashed descendants overlay paths on a same-drive folder move', async () => { + it('rejects a duplicate folder name instead of returning a folder absent from the tree', async () => { const fm = await createInitializedFileManager(); const drive = fm.driveList[0]; - await fm.createFolder(drive.id, '', 'Docs'); - const descendantTopic = Topic.fromString('doc-a').toString(); - drive.trashedNodes = [{ topic: descendantTopic, type: NodeType.File, path: 'Docs/a.txt' }]; + const first = await fm.createFolder(drive.id, '', 'Documents'); - await fm.move('Docs', 'Archive', drive.id); + await expect(fm.createFolder(drive.id, '', 'Documents')).rejects.toThrow(/Node already exists at "Documents"/); - expect(drive.trashedNodes).toEqual([{ topic: descendantTopic, type: NodeType.File, path: 'Archive/a.txt' }]); + const driveMantaray = (fm as any).store.getManifestCache(drive.topic) as MantarayNode; + expect(driveMantaray.find('Documents')?.metadata?.[MANIFEST_METADATA_NODE_TOPIC]).toBe(first.topic); }); - it('relocates trashed descendants to the target drive on a cross-drive folder move', async () => { + it('does not mint a folder feed for a rejected duplicate', async () => { const fm = await createInitializedFileManager(); - await fm.createDrive(otherMockBatchId, 'Target Drive'); - const source = fm.driveList[0]; - const target = fm.driveList[1]; - await fm.createFolder(source.id, '', 'Docs'); + const drive = fm.driveList[0]; - const descendantTopic = Topic.fromString('doc-a').toString(); - source.trashedNodes = [{ topic: descendantTopic, type: NodeType.File, path: 'Docs/a.txt' }]; + await fm.createFolder(drive.id, '', 'Documents'); - await fm.move('Docs', 'Archive', source.id, target.id); + const uploadDataSpy = jest.spyOn(Bee.prototype, 'uploadData'); + uploadDataSpy.mockClear(); - expect(source.trashedNodes).toEqual([]); - expect(target.trashedNodes).toContainEqual({ - topic: descendantTopic, - type: NodeType.File, - path: 'Archive/a.txt', + await expect(fm.createFolder(drive.id, '', 'Documents')).rejects.toThrow(/already exists/); + + expect(uploadDataSpy).not.toHaveBeenCalled(); + }); + + it('rejects a folder name already taken by a file', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + + await fm.uploadFile(drive.id, { path: 'notes', ...makeUploadSource('package.json') }); + + await expect(fm.createFolder(drive.id, '', 'notes')).rejects.toThrow(/Node already exists at "notes"/); + }); + + it('reports the full path of the conflict for a nested parent', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + + await fm.createFolder(drive.id, '', 'outer'); + + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FeedIndex.fromBigInt(0n), + feedIndexNext: FeedIndex.fromBigInt(1n), + payload: { + toJSON: () => ({ reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }), + }, }); + + await fm.createFolder(drive.id, 'outer', 'inner'); + + await expect(fm.createFolder(drive.id, 'outer', 'inner')).rejects.toThrow( + /Node already exists at "outer\/inner"/, + ); + }); + }); + + describe('move', () => { + it('rewrites descendant record paths on a same-drive folder move', async () => { + const fm = await createInitializedFileManager(); + const drive = fm.driveList[0]; + await fm.createFolder(drive.id, '', 'Docs'); + seedRecords(fm, seedDummyFile(drive, 'Docs/a.txt', SWARM_ZERO_ADDRESS.toString(), owner, actPublisher)); + + await fm.move('Docs', 'Archive', drive.id); + + expect(fm.recordList.some((f) => f.path === 'Archive/a.txt')).toBe(true); + expect(fm.recordList.some((f) => f.path === 'Docs/a.txt')).toBe(false); }); it('throws when trying to move the drive root', async () => { diff --git a/tests/unit/init.spec.ts b/tests/unit/init.spec.ts index 595960c..a9efcc8 100644 --- a/tests/unit/init.spec.ts +++ b/tests/unit/init.spec.ts @@ -1,4 +1,4 @@ -import { Bee } from '@ethersphere/bee-js'; +import { Bee, FeedIndex, Topic } from '@ethersphere/bee-js'; import { BEE_URL, createInitializedFileManager, DEFAULT_MOCK_SIGNER, DUMMY_BATCH_ID } from '../utils'; @@ -7,7 +7,9 @@ import { applyDefaultMocks, mockPostageBatch } from './mock'; import { EventEmitterBase } from '@/eventEmitter'; import { FileManagerBase } from '@/fileManager'; import { FileManagerEvents, SignerError } from '@/utils'; -import { ADMIN_STAMP_LABEL } from '@/utils/constants'; +import { getFeedData } from '@/utils/bee'; +import { ADMIN_DRIVE_NAME, FEED_INDEX_ZERO, SWARM_ZERO_ADDRESS } from '@/utils/constants'; +import { getAllNodeEntries } from '@/utils/mantaray'; describe('Initialization and construction', () => { beforeEach(async () => { @@ -80,6 +82,58 @@ describe('Initialization and construction', () => { expect(fm.driveList.length).toBeGreaterThan(0); expect(fm.recordList).toHaveLength(0); }); + + it('reports failure and rolls partial state back, leaving the instance retryable', async () => { + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + const emitter = new EventEmitterBase(); + const events: boolean[] = []; + emitter.on(FileManagerEvents.INITIALIZED, (ok: boolean) => events.push(ok)); + + const fm = new FileManagerBase(bee, emitter); + jest.spyOn(Bee.prototype, 'getNodeAddresses').mockRejectedValueOnce(new Error('bee offline')); + + await fm.initialize(); + + expect(events).toEqual([false]); + expect(fm.isInitialized).toBe(false); + expect(fm.driveList).toHaveLength(0); + expect(fm.recordList).toHaveLength(0); + + await fm.initialize(); + + expect(events).toEqual([false, true]); + expect(fm.isInitialized).toBe(true); + }); + + it('recovers from a failure raised after the admin manifest was already cached', async () => { + const bee = new Bee(BEE_URL, { signer: DEFAULT_MOCK_SIGNER }); + const emitter = new EventEmitterBase(); + const events: boolean[] = []; + emitter.on(FileManagerEvents.INITIALIZED, (ok: boolean) => events.push(ok)); + + // A resolvable state feed, so initialize() gets as far as loading the admin manifest. + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FEED_INDEX_ZERO, + feedIndexNext: FeedIndex.fromBigInt(1n), + payload: { + toUint8Array: () => Topic.fromString('state-feed').toUint8Array(), + toJSON: () => ({ reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }), + }, + }); + + const fm = new FileManagerBase(bee, emitter); + (getAllNodeEntries as jest.Mock).mockImplementationOnce(() => { + throw new Error('corrupt admin manifest'); + }); + + await fm.initialize(); + expect(events).toEqual([false]); + expect(fm.isInitialized).toBe(false); + + await fm.initialize(); + expect(events).toEqual([false, true]); + expect(fm.isInitialized).toBe(true); + }); }); describe('reinitialization', () => { @@ -92,7 +146,7 @@ describe('Initialization and construction', () => { { ...mockPostageBatch, usable: true, - label: ADMIN_STAMP_LABEL, + label: ADMIN_DRIVE_NAME, }, ]); @@ -162,7 +216,7 @@ describe('Initialization and construction', () => { { ...mockPostageBatch, usable: false, - label: ADMIN_STAMP_LABEL, + label: ADMIN_DRIVE_NAME, }, ]); @@ -223,7 +277,7 @@ describe('Initialization and construction', () => { { ...mockPostageBatch, usable: false, - label: ADMIN_STAMP_LABEL, + label: ADMIN_DRIVE_NAME, }, ]); diff --git a/tests/unit/mock.ts b/tests/unit/mock.ts index 39884bb..fd7995a 100644 --- a/tests/unit/mock.ts +++ b/tests/unit/mock.ts @@ -28,7 +28,7 @@ import { DEFAULT_MOCK_SIGNER, DUMMY_BATCH_ID } from '../utils'; import { type FileManagerBase } from '@/fileManager'; import { type DriveInfo, type FileRecord, NodeType } from '@/types'; import { fetchStamp, getFeedData } from '@/utils/bee'; -import { ADMIN_STAMP_LABEL, FEED_INDEX_ZERO, SWARM_ZERO_ADDRESS } from '@/utils/constants'; +import { ADMIN_DRIVE_NAME, FEED_INDEX_ZERO, SWARM_ZERO_ADDRESS } from '@/utils/constants'; import { getAllNodeEntries, loadMantaray } from '@/utils/mantaray'; export function createMockMantarayNode(all = true): MantarayNode { @@ -207,7 +207,7 @@ export function loadStampListMock(): PostageBatch[] { utilization: 5, usable: true, usageText: '2%', - label: ADMIN_STAMP_LABEL, + label: ADMIN_DRIVE_NAME, depth: 22, amount: '990' as NumberString, bucketDepth: 30, diff --git a/tests/unit/trash.spec.ts b/tests/unit/trash.spec.ts index 620f36b..b94fbe8 100644 --- a/tests/unit/trash.spec.ts +++ b/tests/unit/trash.spec.ts @@ -1,14 +1,20 @@ -import { Bytes, FeedIndex, Identifier, type MantarayNode, RedundancyLevel, Topic } from '@ethersphere/bee-js'; +import { FeedIndex, Identifier, type MantarayNode, Topic } from '@ethersphere/bee-js'; -import { createInitializedFileManager, DEFAULT_MOCK_SIGNER, DUMMY_BATCH_ID, makeUploadSource } from '../utils'; +import { createInitializedFileManager, DEFAULT_MOCK_SIGNER, makeUploadSource } from '../utils'; -import { applyDefaultMocks, createMockNodeAddresses, seedRecords } from './mock'; +import { applyDefaultMocks, createMockNodeAddresses, seedDummyFile, seedRecords } from './mock'; import { type FileManagerBase } from '@/fileManager'; -import { type DriveInfo, type FileRecord, type FolderInfo, NodeStatus, NodeType } from '@/types'; +import { type DriveInfo, type FileRecord, NodeStatus, NodeType } from '@/types'; import { FileManagerEvents } from '@/utils'; import { getFeedData } from '@/utils/bee'; -import { SWARM_ZERO_ADDRESS } from '@/utils/constants'; +import { + MANIFEST_METADATA_NODE_TOPIC, + MANIFEST_METADATA_TRASHED_FROM, + SWARM_ZERO_ADDRESS, + TRASH_FOLDER_NAME, +} from '@/utils/constants'; +import { getAllNodeEntries } from '@/utils/mantaray'; describe('Lifecycle management', () => { const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); @@ -27,127 +33,235 @@ describe('Lifecycle management', () => { fileRecord = fm.recordList.find((f) => f.path === 'notes.txt')!; }); - describe('trashFile', () => { - it('records the file in the drive trash overlay without a version bump, and emits FILE_TRASHED', async () => { + const validFolderFeed = (): void => { + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FeedIndex.fromBigInt(0n), + feedIndexNext: FeedIndex.fromBigInt(1n), + payload: { + toJSON: () => ({ reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }), + }, + }); + }; + + const driveRoot = (): MantarayNode => (fm as any).store.getManifestCache(drive.topic) as MantarayNode; + const trashNode = (): MantarayNode => { + const trashTopic = driveRoot().find(TRASH_FOLDER_NAME)?.metadata?.[MANIFEST_METADATA_NODE_TOPIC]; + return (fm as any).store.getManifestCache(trashTopic) as MantarayNode; + }; + + describe('trash', () => { + it('relocates the fork into .trash keyed by topic, stamping the origin path', async () => { const handler = jest.fn(); fm.emitter.on(FileManagerEvents.FILE_TRASHED, handler); const versionBefore = fileRecord.version; - await fm.trashFile(fileRecord); + await fm.trash(drive.id, 'notes.txt'); + expect(driveRoot().find('notes.txt')).toBeFalsy(); + const trashedFork = trashNode().find(fileRecord.topic); + expect(trashedFork).toBeTruthy(); + expect(trashedFork?.metadata?.[MANIFEST_METADATA_TRASHED_FROM]).toBe('notes.txt'); + + expect(fileRecord.path).toBe(`${TRASH_FOLDER_NAME}/${fileRecord.topic}`); + expect(fileRecord.trashedFrom).toBe('notes.txt'); expect(fileRecord.status).toBe(NodeStatus.Trashed); expect(fileRecord.version).toBe(versionBefore); - expect(drive.trashedNodes).toEqual([ - { topic: fileRecord.topic, type: NodeType.File, path: fileRecord.path, version: versionBefore }, - ]); - expect(handler).toHaveBeenCalledWith({ record: fileRecord }); + expect(handler).toHaveBeenCalledWith({ + driveId: drive.id, + path: 'notes.txt', + trashedPath: `${TRASH_FOLDER_NAME}/${fileRecord.topic}`, + record: fileRecord, + }); + }); + + it('keeps two same-named files apart in the trash', async () => { + validFolderFeed(); + await fm.createFolder(drive.id, '', 'A'); + await fm.createFolder(drive.id, '', 'B'); + await fm.uploadFile(drive.id, { path: 'A/dup.txt', ...makeUploadSource('package.json') }); + await fm.uploadFile(drive.id, { path: 'B/dup.txt', ...makeUploadSource('package.json') }); + + const inA = fm.recordList.find((f) => f.path === 'A/dup.txt')!; + const inB = fm.recordList.find((f) => f.path === 'B/dup.txt')!; + + await fm.trash(drive.id, 'A/dup.txt'); + await fm.trash(drive.id, 'B/dup.txt'); + + expect(trashNode().find(inA.topic)?.metadata?.[MANIFEST_METADATA_TRASHED_FROM]).toBe('A/dup.txt'); + expect(trashNode().find(inB.topic)?.metadata?.[MANIFEST_METADATA_TRASHED_FROM]).toBe('B/dup.txt'); }); - it('throws if the file is already trashed', async () => { - await fm.trashFile(fileRecord); - await expect(fm.trashFile(fileRecord)).rejects.toThrow(`Already trashed: ${fileRecord.path}`); + it('rewrites descendant record paths when a folder is trashed', async () => { + validFolderFeed(); + const folder = await fm.createFolder(drive.id, '', 'Docs'); + seedRecords(fm, seedDummyFile(drive, 'Docs/a.txt', SWARM_ZERO_ADDRESS.toString(), owner, actPublisher)); + + const handler = jest.fn(); + fm.emitter.on(FileManagerEvents.FOLDER_TRASHED, handler); + + await fm.trash(drive.id, 'Docs'); + + const descendant = fm.recordList.find((f) => f.topic === Topic.fromString('dl-Docs/a.txt').toString())!; + expect(descendant.path).toBe(`${TRASH_FOLDER_NAME}/${folder.topic}/a.txt`); + expect(descendant.status).toBe(NodeStatus.Trashed); + expect(handler).toHaveBeenCalledWith({ + driveId: drive.id, + path: 'Docs', + trashedPath: `${TRASH_FOLDER_NAME}/${folder.topic}`, + folderInfo: expect.objectContaining({ + type: NodeType.Folder, + topic: folder.topic, + driveId: drive.id, + path: `${TRASH_FOLDER_NAME}/${folder.topic}`, + trashedFrom: 'Docs', + status: NodeStatus.Trashed, + }), + }); }); - it('trashFile throws when the drive is not found', async () => { - const ghost: FileRecord = { ...fileRecord, driveId: Identifier.fromString('ghost-drive').toString() }; - await expect(fm.trashFile(ghost)).rejects.toThrow(`Drive with id ${ghost.driveId!.slice(0, 6)} not found`); + it('refuses the drive root, a reserved path and a missing path', async () => { + await expect(fm.trash(drive.id, '/')).rejects.toThrow('Cannot trash drive root'); + await expect(fm.trash(drive.id, TRASH_FOLDER_NAME)).rejects.toThrow(/reserved/); + await expect(fm.trash(drive.id, 'ghost.txt')).rejects.toThrow('Path not found: ghost.txt'); + }); + + it('throws when the drive is not found', async () => { + const ghostDrive = Identifier.fromString('ghost-drive').toString(); + await expect(fm.trash(ghostDrive, 'notes.txt')).rejects.toThrow( + `Drive with id ${ghostDrive.slice(0, 6)} not found`, + ); + }); + + it('leaves the file unreadable through updateFile until it is recovered', async () => { + await fm.trash(drive.id, 'notes.txt'); + + await expect(fm.updateFile(drive.id, fileRecord, { customMetadata: { a: 'b' } })).rejects.toThrow( + /Cannot update a trashed file/, + ); }); }); - describe('recoverFile', () => { - it('removes the file from the overlay and emits FILE_RECOVERED', async () => { - await fm.trashFile(fileRecord); + describe('recover', () => { + it('restores the fork to its stamped origin and emits FILE_RECOVERED', async () => { + await fm.trash(drive.id, 'notes.txt'); + const trashedPath = `${TRASH_FOLDER_NAME}/${fileRecord.topic}`; + const handler = jest.fn(); fm.emitter.on(FileManagerEvents.FILE_RECOVERED, handler); + validFolderFeed(); + + const restoredPath = await fm.recover(drive.id, trashedPath); - await fm.recoverFile(fileRecord); + expect(restoredPath).toBe('notes.txt'); + expect(driveRoot().find('notes.txt')).toBeTruthy(); + expect(trashNode().find(fileRecord.topic)).toBeFalsy(); + expect(driveRoot().find('notes.txt')?.metadata?.[MANIFEST_METADATA_TRASHED_FROM]).toBeUndefined(); + expect(fileRecord.path).toBe('notes.txt'); + expect(fileRecord.trashedFrom).toBeUndefined(); expect(fileRecord.status).toBe(NodeStatus.Active); - expect(drive.trashedNodes).toEqual([]); - expect(handler).toHaveBeenCalledWith({ record: fileRecord }); + expect(handler).toHaveBeenCalledWith({ + driveId: drive.id, + trashedPath, + restoredPath: 'notes.txt', + record: fileRecord, + }); }); - it('throws if the file was never trashed', async () => { - await expect(fm.recoverFile(fileRecord)).rejects.toThrow(`Not trashed, cannot recover: ${fileRecord.path}`); - }); - }); + it('restores to an explicit destination when one is given', async () => { + validFolderFeed(); + await fm.createFolder(drive.id, '', 'Archive'); + await fm.trash(drive.id, 'notes.txt'); - describe('recoverFolder', () => { - it('removes the folder from the overlay and emits FOLDER_RECOVERED', async () => { - const folder: FolderInfo = { - type: NodeType.Folder, - owner, - actPublisher, - topic: Topic.fromString('docs-folder').toString(), - driveId: drive.id, - path: 'Docs', - batchId: DUMMY_BATCH_ID, - redundancyLevel: RedundancyLevel.OFF, - }; + const restoredPath = await fm.recover(drive.id, `${TRASH_FOLDER_NAME}/${fileRecord.topic}`, 'Archive/notes.txt'); - await fm.trashFolder(folder); - const handler = jest.fn(); - fm.emitter.on(FileManagerEvents.FOLDER_RECOVERED, handler); + expect(restoredPath).toBe('Archive/notes.txt'); + expect(fileRecord.path).toBe('Archive/notes.txt'); + expect(driveRoot().find('notes.txt')).toBeFalsy(); + }); - await fm.recoverFolder(folder); + it('refuses an occupied destination instead of overwriting it', async () => { + await fm.trash(drive.id, 'notes.txt'); + validFolderFeed(); + await fm.uploadFile(drive.id, { path: 'notes.txt', ...makeUploadSource('package.json') }); - expect(folder.status).toBe(NodeStatus.Active); - expect(drive.trashedNodes).toEqual([]); - expect(handler).toHaveBeenCalledWith({ folder }); + await expect(fm.recover(drive.id, `${TRASH_FOLDER_NAME}/${fileRecord.topic}`)).rejects.toThrow( + 'Destination already exists: notes.txt', + ); + expect(trashNode().find(fileRecord.topic)).toBeTruthy(); }); - }); - describe('trashFolder', () => { - it('records a folder in the overlay and emits FOLDER_TRASHED', async () => { - const folder: FolderInfo = { - type: NodeType.Folder, - owner, - actPublisher, - topic: Topic.fromString('docs-folder').toString(), - driveId: drive.id, - path: 'Docs', - batchId: DUMMY_BATCH_ID, - redundancyLevel: RedundancyLevel.OFF, - }; - const handler = jest.fn(); - fm.emitter.on(FileManagerEvents.FOLDER_TRASHED, handler); + it('drops the destination manifest when its write fails, keeping the node in trash', async () => { + await fm.trash(drive.id, 'notes.txt'); + validFolderFeed(); + // Captured up front: the helper reads it through the drive root, which this failure evicts. + const trashTopic = driveRoot().find(TRASH_FOLDER_NAME)?.metadata?.[MANIFEST_METADATA_NODE_TOPIC]; + + // The destination is written first, so its failure must take the unpersisted fork down with it + // and leave the node referenced from trash. + // eslint-disable-next-line @typescript-eslint/no-require-imports, no-undef + const mantaray = require('@/utils/mantaray'); + const saveSpy = jest.spyOn(mantaray, 'saveNodeManifest').mockRejectedValueOnce(new Error('dest write failed')); + + await expect(fm.recover(drive.id, `${TRASH_FOLDER_NAME}/${fileRecord.topic}`)).rejects.toThrow( + 'dest write failed', + ); - await fm.trashFolder(folder); + expect(saveSpy).toHaveBeenCalledTimes(1); + expect((fm as any).store.getManifestCache(drive.topic)).toBeUndefined(); + expect((fm as any).store.getManifestCache(trashTopic).find(fileRecord.topic)).toBeTruthy(); - expect(folder.status).toBe(NodeStatus.Trashed); - expect(drive.trashedNodes).toContainEqual({ topic: folder.topic, type: NodeType.Folder, path: folder.path }); - expect(handler).toHaveBeenCalledWith({ folder }); + saveSpy.mockRestore(); + }); + + it('rejects a path that is not a trashed node, and a node that is not trashed', async () => { + await expect(fm.recover(drive.id, 'notes.txt')).rejects.toThrow(/Not a trashed node path/); + await expect(fm.recover(drive.id, `${TRASH_FOLDER_NAME}/${fileRecord.topic}`)).rejects.toThrow( + /Not trashed, cannot recover/, + ); }); }); - describe('listTrash', () => { - it('hydrates the overlay into trashed NodeEntries', async () => { - await fm.trashFile(fileRecord); + describe('emptyTrash', () => { + it('de-references every trashed node in one pass and drops their records', async () => { + await fm.trash(drive.id, 'notes.txt'); + validFolderFeed(); - (getFeedData as jest.Mock).mockResolvedValue({ - feedIndex: FeedIndex.fromBigInt(0n), - feedIndexNext: FeedIndex.fromBigInt(1n), - payload: new Bytes(SWARM_ZERO_ADDRESS.toUint8Array()), - }); - const spyFetch = jest - .spyOn((fm as any).store, 'getRecord') - .mockResolvedValue({ ...fileRecord, status: undefined }); + const handler = jest.fn(); + fm.emitter.on(FileManagerEvents.TRASH_EMPTIED, handler); + (getAllNodeEntries as jest.Mock).mockReturnValue([ + { + path: fileRecord.topic, + type: NodeType.File, + topic: fileRecord.topic, + rawMetadata: { [MANIFEST_METADATA_TRASHED_FROM]: 'notes.txt' }, + }, + ]); + + const count = await fm.emptyTrash(drive.id); - const trashed = await fm.listTrash(drive.id); + expect(count).toBe(1); + expect(trashNode().find(fileRecord.topic)).toBeFalsy(); + expect(fm.recordList.some((f) => f.topic === fileRecord.topic)).toBe(false); + expect(handler).toHaveBeenCalledWith({ driveId: drive.id, count: 1 }); + }); - expect(trashed).toHaveLength(1); - expect(trashed[0].topic).toBe(fileRecord.topic); - expect(trashed[0].status).toBe(NodeStatus.Trashed); - expect(trashed[0].path).toBe(fileRecord.path); + it('is a no-op for a drive that never had a trash folder', async () => { + expect(await fm.emptyTrash(drive.id)).toBe(0); + }); + }); - spyFetch.mockRestore(); + describe('listTrash', () => { + it('returns [] for a drive that never had anything trashed', async () => { + expect(await fm.listTrash(drive.id)).toEqual([]); }); }); describe('forget', () => { - it('throws when attempting to forget the drive root', async () => { + it('throws when attempting to forget the drive root or the trash folder', async () => { await expect(fm.forget(drive.id, '/')).rejects.toThrow('Cannot forget drive root'); await expect(fm.forget(drive.id, '')).rejects.toThrow('Cannot forget drive root'); + await expect(fm.forget(drive.id, TRASH_FOLDER_NAME)).rejects.toThrow('use emptyTrash'); }); it('removes a file fork and its recordList entry, emitting FILE_FORGOTTEN', async () => { @@ -161,26 +275,16 @@ describe('Lifecycle management', () => { await fm.forget(drive.id, 'package.json'); expect(fm.recordList.find((f) => f.path === 'package.json')).toBeUndefined(); - expect(handler).toHaveBeenCalledWith({ record: uploaded, path: 'package.json' }); + expect(handler).toHaveBeenCalledWith({ driveId: drive.id, path: 'package.json', record: uploaded }); const driveMantaray = (fm as any).store.getManifestCache(drive.topic) as MantarayNode; expect(driveMantaray.find('package.json')).toBeFalsy(); }); it('removes a folder fork and purges all descendant recordList entries', async () => { - await fm.createFolder(drive.id, '', 'Docs'); - - seedRecords(fm, { - type: NodeType.File, - batchId: DUMMY_BATCH_ID, - owner, - actPublisher, - topic: Topic.fromString('doc-a').toString(), - driveId: drive.id, - path: 'Docs/a.txt', - content: { reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }, - redundancyLevel: RedundancyLevel.OFF, - }); + const folder = await fm.createFolder(drive.id, '', 'Docs'); + + seedRecords(fm, seedDummyFile(drive, 'Docs/a.txt', SWARM_ZERO_ADDRESS.toString(), owner, actPublisher)); const handler = jest.fn(); fm.emitter.on(FileManagerEvents.FOLDER_FORGOTTEN, handler); @@ -188,20 +292,24 @@ describe('Lifecycle management', () => { await fm.forget(drive.id, 'Docs'); expect(fm.recordList.some((f) => f.path.startsWith('Docs/'))).toBe(false); - expect(handler).toHaveBeenCalledWith({ driveInfo: drive, path: 'Docs' }); + expect(handler).toHaveBeenCalledWith({ + driveId: drive.id, + path: 'Docs', + folderInfo: expect.objectContaining({ + type: NodeType.Folder, + topic: folder.topic, + driveId: drive.id, + path: 'Docs', + status: NodeStatus.Active, + }), + }); }); it('forgets only the targeted file when a same-named file exists in another folder', async () => { await fm.createFolder(drive.id, '', 'A'); await fm.createFolder(drive.id, '', 'B'); - (getFeedData as jest.Mock).mockResolvedValue({ - feedIndex: FeedIndex.fromBigInt(0n), - feedIndexNext: FeedIndex.fromBigInt(1n), - payload: { - toJSON: () => ({ reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }), - }, - }); + validFolderFeed(); await fm.uploadFile(drive.id, { path: 'A/dup.txt', ...makeUploadSource('package.json') }); await fm.uploadFile(drive.id, { path: 'B/dup.txt', ...makeUploadSource('package.json') }); @@ -210,14 +318,13 @@ describe('Lifecycle management', () => { expect(fm.recordList.find((f) => f.path === 'A/dup.txt')).toBeDefined(); expect(inB).toBeDefined(); - await fm.trashFile(inB); - expect(drive.trashedNodes?.some((n) => n.path === 'B/dup.txt')).toBe(true); + await fm.trash(drive.id, 'B/dup.txt'); + expect(inB.path).toBe(`${TRASH_FOLDER_NAME}/${inB.topic}`); await fm.forget(drive.id, 'A/dup.txt'); expect(fm.recordList.find((f) => f.path === 'A/dup.txt')).toBeUndefined(); - expect(fm.recordList.find((f) => f.path === 'B/dup.txt')).toBeDefined(); - expect(drive.trashedNodes?.some((n) => n.path === 'B/dup.txt')).toBe(true); + expect(fm.recordList.find((f) => f.topic === inB.topic)).toBeDefined(); }); }); }); diff --git a/tests/unit/version.spec.ts b/tests/unit/version.spec.ts index 026b1ec..3fe84f4 100644 --- a/tests/unit/version.spec.ts +++ b/tests/unit/version.spec.ts @@ -1,15 +1,32 @@ -import { Bytes, FeedIndex, Identifier, PublicKey, RedundancyLevel, Topic } from '@ethersphere/bee-js'; +import { + BatchId, + Bee, + Bytes, + FeedIndex, + Identifier, + type MantarayNode, + PublicKey, + RedundancyLevel, + Topic, +} from '@ethersphere/bee-js'; -import { createInitializedFileManager, DEFAULT_MOCK_SIGNER, DUMMY_BATCH_ID } from '../utils'; +import { + createInitializedFileManager, + DEFAULT_MOCK_SIGNER, + DUMMY_BATCH_ID, + getEncodedData, + makeUploadSource, +} from '../utils'; import { applyDefaultMocks, createMockNodeAddresses, seedRecords } from './mock'; import { type FileManagerBase } from '@/fileManager'; +import { type MantarayStore } from '@/mantarayStore'; import { type FileRecord, NodeType } from '@/types'; import { type FeedResultWithIndex } from '@/types/utils'; import { FileManagerEvents } from '@/utils'; import { getFeedData } from '@/utils/bee'; -import { FEED_INDEX_ZERO, SWARM_ZERO_ADDRESS } from '@/utils/constants'; +import { FEED_INDEX_ZERO, MANIFEST_METADATA_NODE_VERSION, SWARM_ZERO_ADDRESS } from '@/utils/constants'; describe('Version control', () => { const owner = DEFAULT_MOCK_SIGNER.publicKey().address().toString(); @@ -55,6 +72,7 @@ describe('Version control', () => { dummyFi.topic, new PublicKey(actPublisher).toCompressedHex(), rawMock, + { isHeadRead: false }, undefined, ); expect(got).toBe(fakeFi); @@ -86,6 +104,90 @@ describe('Version control', () => { `File feed not found for topic: ${dummyFi.topic.slice(0, 6)}`, ); }); + + it('stamps the passed record’s absolute path over the leaf stored in the slot', async () => { + const storedSlot = { ...dummyFi, version: FeedIndex.fromBigInt(1n).toString(), path: 'x.txt' }; + const spyFetch = jest.spyOn((fm as any).store, 'getRecord').mockResolvedValue(storedSlot); + + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FeedIndex.fromBigInt(1n), + feedIndexNext: FeedIndex.fromBigInt(2n), + payload: new Bytes(SWARM_ZERO_ADDRESS.toUint8Array()), + }); + + const got = await fm.getFileVersion({ ...dummyFi, path: 'nested/deep/x.txt' }, FeedIndex.fromBigInt(1n)); + + expect(got.path).toBe('nested/deep/x.txt'); + + spyFetch.mockRestore(); + }); + + it('prefers the cached head’s path, so a version fetched after a move keeps the new location', async () => { + seedRecords(fm, { ...dummyFi, path: 'moved/x.txt', version: FeedIndex.fromBigInt(5n).toString() }); + + const spyFetch = jest + .spyOn((fm as any).store, 'getRecord') + .mockResolvedValue({ ...dummyFi, version: FEED_INDEX_ZERO.toString(), path: 'x.txt' }); + + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FEED_INDEX_ZERO, + feedIndexNext: FeedIndex.fromBigInt(1n), + payload: new Bytes(SWARM_ZERO_ADDRESS.toUint8Array()), + }); + + // An older slot than the cached head, so the cache shortcut does not apply. + const got = await fm.getFileVersion({ ...dummyFi, path: 'stale/x.txt' }, FEED_INDEX_ZERO); + + expect(got.path).toBe('moved/x.txt'); + + spyFetch.mockRestore(); + }); + + it('leaves the cached head refs alone when an older version is read', async () => { + const headRefs = { reference: 'a'.repeat(64), historyRef: 'b'.repeat(64) }; + const oldRefs = { reference: 'c'.repeat(64), historyRef: 'd'.repeat(64) }; + + seedRecords(fm, { ...dummyFi, version: FeedIndex.fromBigInt(5n).toString() }); + const store = (fm as any).store as MantarayStore; + store.setNodeRef(dummyTopic, headRefs); + + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FEED_INDEX_ZERO, + feedIndexNext: FeedIndex.fromBigInt(1n), + payload: { toJSON: () => oldRefs }, + }); + jest + .spyOn(Bee.prototype, 'downloadData') + .mockResolvedValue(getEncodedData(JSON.stringify({ ...dummyFi, version: FEED_INDEX_ZERO.toString() }))); + + const got = await fm.getFileVersion(dummyFi, FEED_INDEX_ZERO); + + expect(got.content).toEqual(dummyFi.content); + expect(store.getNodeRef(dummyTopic)).toEqual(headRefs); + }); + + it('refreshes the cached refs when the head itself is read', async () => { + const staleRefs = { reference: 'a'.repeat(64), historyRef: 'b'.repeat(64) }; + const headRefs = { reference: 'c'.repeat(64), historyRef: 'd'.repeat(64) }; + + const store = (fm as any).store as MantarayStore; + store.setNodeRef(dummyTopic, staleRefs); + + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FeedIndex.fromBigInt(7n), + feedIndexNext: FeedIndex.fromBigInt(8n), + payload: { toJSON: () => headRefs }, + }); + jest + .spyOn(Bee.prototype, 'downloadData') + .mockResolvedValue( + getEncodedData(JSON.stringify({ ...dummyFi, version: FeedIndex.fromBigInt(7n).toString() })), + ); + + await fm.getFileVersion(dummyFi); + + expect(store.getNodeRef(dummyTopic)).toEqual(headRefs); + }); }); describe('restoreFileVersion', () => { @@ -130,5 +232,58 @@ describe('Version control', () => { expect(getFeedData).not.toHaveBeenCalled(); }); + + it('refuses to stamp a version onto a same-named fork belonging to a different node', async () => { + await fm.createDrive(new BatchId('4'.repeat(64)), 'Version Drive'); + const di = fm.driveList[1]; + await fm.uploadFile(di.id, { path: 'report.pdf', ...makeUploadSource('package.json') }); + const victim = fm.recordList.find((fr) => fr.path === 'report.pdf')!; + + const impostor: FileRecord = { + ...dummyFi, + driveId: di.id, + topic: Topic.fromString('impostor-topic').toString(), + path: 'report.pdf', + version: FEED_INDEX_ZERO.toString(), + }; + + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FeedIndex.fromBigInt(3n), + feedIndexNext: FeedIndex.fromBigInt(4n), + payload: { + toJSON: () => ({ reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }), + }, + }); + + const saveRecordSpy = jest.spyOn((fm as any).store, 'saveRecord'); + + await expect(fm.restoreFileVersion(impostor)).rejects.toThrow(/belongs to a different node/); + + // Validated before the feed write, and the victim's fork version is untouched. + expect(saveRecordSpy).not.toHaveBeenCalled(); + const driveMantaray = (fm as any).store.getManifestCache(di.topic) as MantarayNode; + expect(driveMantaray.find('report.pdf')?.metadata?.[MANIFEST_METADATA_NODE_VERSION]).toBe(victim.version); + }); + + it('throws when the file has no fork at its resolved path, without advancing the feed', async () => { + await fm.createDrive(new BatchId('5'.repeat(64)), 'Ghost Drive'); + const di = fm.driveList[1]; + + (getFeedData as jest.Mock).mockResolvedValue({ + feedIndex: FeedIndex.fromBigInt(3n), + feedIndexNext: FeedIndex.fromBigInt(4n), + payload: { + toJSON: () => ({ reference: SWARM_ZERO_ADDRESS.toString(), historyRef: SWARM_ZERO_ADDRESS.toString() }), + }, + }); + + const saveRecordSpy = jest.spyOn((fm as any).store, 'saveRecord'); + + await expect( + fm.restoreFileVersion({ ...dummyFi, driveId: di.id, path: 'gone.txt', version: FEED_INDEX_ZERO.toString() }), + ).rejects.toThrow('Path not found: gone.txt'); + + expect(saveRecordSpy).not.toHaveBeenCalled(); + }); }); }); diff --git a/tests/utils.ts b/tests/utils.ts index 497a516..e20630d 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -1,4 +1,4 @@ -import { type BatchId, Bee, type BeeRequestOptions, PrivateKey, RedundancyLevel } from '@ethersphere/bee-js'; +import { type BatchId, Bee, type BeeRequestOptions, Bytes, PrivateKey, RedundancyLevel } from '@ethersphere/bee-js'; import * as fs from 'fs'; import path from 'path'; import { isNode } from 'std-env'; @@ -145,3 +145,23 @@ export async function createInitializedFileManager( return fm; } + +export function abortAfterFirstRecordWrite(fm: FileManagerBase, controller: AbortController): void { + const store = (fm as any).store; + const saveRecord = store.saveRecord.bind(store); + + let armed = true; + jest.spyOn(store, 'saveRecord').mockImplementation(async (...args: unknown[]) => { + const result = await saveRecord(...args); + if (armed) { + armed = false; + controller.abort(); + } + + return result; + }); +} + +export const getEncodedData = (input: string): Bytes => { + return new Bytes(new TextEncoder().encode(input)); +}; diff --git a/tsup.config.ts b/tsup.config.ts index d976755..d66f01d 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -17,7 +17,6 @@ const stubNodeInBrowser = stubForeignPlatform('stub-node-in-browser', /(?:^|\/)( 'processUploadNode', 'isDir', 'readFile', - 'getContentType', ]); const stubBrowserInNode = stubForeignPlatform('stub-browser-in-node', /(?:^|\/)upload-browser$/, [ 'processUploadBrowser',