FS
ForgeStorage

ForgeFileStore

File storage for binary data — images, videos, PDFs, JSON responses, downloads. All paths are relative to the store's base directory. The store handles directory creation, file protection, and path resolution.

Import
import ForgeFileStore

FileStoring Protocol

All file stores conform to FileStoring. Depend on the protocol for testability.

FileStoring.swift
public protocol FileStoring: Sendable {
    // Write
    func save(_ data: Data, to path: String, overwrite: Bool) async throws
    func copy(from sourceURL: URL, to path: String, overwrite: Bool) async throws
    func move(from sourceURL: URL, to path: String, overwrite: Bool) async throws

    // Read
    func load(_ path: String) async throws -> Data
    func url(for path: String) -> URL

    // Query
    func exists(_ path: String) -> Bool
    func info(for path: String) throws -> StoredFile
    func list(in directory: String?, recursive: Bool) async throws -> [StoredFile]
    func size(of path: String) throws -> UInt64
    func totalSize() async throws -> UInt64

    // Delete
    func delete(_ path: String) async throws
    func deleteDirectory(_ path: String) async throws
    func deleteAll() async throws

    // Directory
    func createDirectory(_ path: String) async throws
}

DiskFileStore

Production implementation. Each instance manages a namespaced subdirectory within a base iOS directory. Multiple stores can coexist without collision.

DiskFileStore.swift
let store = DiskFileStore(
    directory: .caches,
    namespace: "taskflow-cache",
    fileProtection: .completeUntilFirstUserAuthentication // default
)

Directory Options

Directory Backed Up Purged by System Use Case
.documents Yes No User-created files (photos, exports, recordings)
.applicationSupport Yes No App-managed data (downloaded assets, generated files)
.caches No Yes (low disk) Rebuildable data (thumbnails, API responses)

Namespace

The namespace creates an isolated subdirectory. Use different namespaces for different concerns.

Namespaces.swift
// Permanent user files (task attachments, exports)
let documents = DiskFileStore(directory: .documents, namespace: "taskflow-files")

// App-managed persistent data
let appData = DiskFileStore(directory: .applicationSupport, namespace: "taskflow-data")

// Rebuildable cache (thumbnails, avatars)
let cache = DiskFileStore(directory: .caches, namespace: "taskflow-cache")

File Protection

Applied to the namespace directory and every file written. Use .complete for sensitive files that should be inaccessible when the device is locked.

FileProtection.swift
// Default: accessible after first unlock (good for background tasks)
let store = DiskFileStore(directory: .applicationSupport, namespace: "taskflow-data")

// Complete protection: only accessible when device is unlocked
let sensitive = DiskFileStore(
    directory: .documents,
    namespace: "taskflow-secure",
    fileProtection: .complete
)

Write Operations

save(_:to:overwrite:)

Saves raw data to a path. Creates intermediate directories automatically. Defaults to overwriting existing files.

save.swift
// Basic save (overwrites by default)
try await store.save(imageData, to: "tasks/task_42/attachment.jpg")

// Prevent overwriting
try await store.save(data, to: "exports/project_report.pdf", overwrite: false)
// Throws FileStoreError.fileAlreadyExists if file exists

copy(from:to:overwrite:)

Copies a file from an external URL into the store. Use for files from UIDocumentPickerViewController, the photo library, or URLSession downloads.

copy.swift
// Copy from document picker
try await store.copy(from: pickerURL, to: "tasks/task_42/brief.pdf")

// Copy from temp download
let tempURL = try await URLSession.shared.download(from: remoteURL).0
try await store.copy(from: tempURL, to: "downloads/project_assets.zip")

move(from:to:overwrite:)

Moves a file into the store. More efficient than copy for large files since no data duplication occurs. The source file is removed after a successful move.

move.swift
try await store.move(from: tempFileURL, to: "videos/recording.mp4")

Read Operations

load(_:)

Loads the raw data for a file. Throws FileStoreError.fileNotFound if the file does not exist.

load.swift
let data = try await store.load("tasks/task_42/attachment.jpg")
let image = UIImage(data: data)

url(for:)

Returns the absolute file URL. Does not verify the file exists — use exists(_:) if needed.

url.swift
let fileURL = store.url(for: "videos/recording.mp4")
let player = AVPlayer(url: fileURL)

// Share via UIActivityViewController
let activityVC = UIActivityViewController(
    activityItems: [fileURL],
    applicationActivities: nil
)

Query Operations

exists(_:)

Returns true if a file exists at the given path.

exists.swift
if store.exists("tasks/task_42/attachment.jpg") {
    let data = try await store.load("tasks/task_42/attachment.jpg")
}

info(for:)

Returns a StoredFile struct with metadata. Throws FileStoreError.fileNotFound if the file does not exist.

info.swift
let info = try store.info(for: "tasks/task_42/attachment.jpg")
print(info.name)         // "attachment.jpg"
print(info.relativePath) // "tasks/task_42/attachment.jpg"
print(info.size)         // 45_312 (bytes)
print(info.createdAt)    // Optional<Date>
print(info.modifiedAt)   // Optional<Date>
print(info.isDirectory)  // false

StoredFile

Metadata struct returned by info(for:) and list(in:recursive:).

StoredFile.swift
public struct StoredFile: Sendable, Equatable {
    public let name: String          // "photo.jpg"
    public let relativePath: String  // "avatars/photo.jpg"
    public let url: URL              // Absolute file URL
    public let size: UInt64          // Size in bytes
    public let createdAt: Date?
    public let modifiedAt: Date?
    public let isDirectory: Bool
}

list(in:recursive:)

Lists files in a subdirectory. Pass nil for the store root. Set recursive: true to include nested subdirectories.

list.swift
// List files in a subdirectory
let taskFiles = try await store.list(in: "tasks/task_42")

// List all files recursively
let allFiles = try await store.list(in: nil, recursive: true)

// Default: non-recursive, store root
let rootFiles = try await store.list()

size(of:) / totalSize()

Get file or total store size in bytes.

size.swift
let fileSize = try store.size(of: "tasks/task_42/attachment.jpg") // UInt64
let total = try await store.totalSize() // Total bytes across all files

let megabytes = Double(total) / 1_048_576
print("Cache size: \(String(format: "%.1f", megabytes)) MB")

Delete Operations

delete(_:)

Deletes a single file. Throws FileStoreError.fileNotFound if it does not exist.

delete.swift
try await store.delete("tasks/task_42/attachment.jpg")

deleteDirectory(_:)

Deletes an entire subdirectory and all its contents.

deleteDirectory.swift
try await store.deleteDirectory("tasks/task_42")

deleteAll()

Deletes all files in the store. Recreates the base directory.

deleteAll.swift
try await store.deleteAll()

createDirectory(_:)

Creates a subdirectory. No-op if it already exists.

createDirectory.swift
try await store.createDirectory("projects/q2-launch")

Convenience Methods

Extensions on FileStoring for common operations.

saveImage / saveImageAsPNG / loadImage

Available on iOS only (UIKit required).

ImageConvenience.swift
// Save as JPEG (default quality 0.8)
try await store.saveImage(image, to: "avatars/user_123.jpg")

// Custom compression quality
try await store.saveImage(
    image,
    to: "photos/high_quality.jpg",
    compressionQuality: 0.95
)

// Save as PNG (lossless, supports transparency)
try await store.saveImageAsPNG(icon, to: "icons/app_icon.png")

// Load image
let avatar: UIImage? = try await store.loadImage("avatars/user_123.jpg")

saveJSON / loadJSON / loadJSONIfExists

Encode and decode Codable types as JSON files.

JSONConvenience.swift
struct TaskDetails: Codable {
    let title: String
    let dueDate: Date
    let projectId: String
}

// Save
try await store.saveJSON(taskDetails, to: "tasks/task_42.json")

// Load (throws if file not found)
let task: TaskDetails = try await store.loadJSON("tasks/task_42.json")

// Load if exists (returns nil instead of throwing)
let cached: TaskDetails? = try await store.loadJSONIfExists("tasks/task_42.json")

// Custom encoder/decoder
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
try await store.saveJSON(taskDetails, to: "tasks/task_42.json", encoder: encoder)

hashedFilename(for:extension:)

Generates a deterministic filename from a string (typically a URL). Useful for cache keys.

HashedFilename.swift
let url = "https://example.com/images/photo.jpg"
let filename = store.hashedFilename(for: url, extension: "jpg")
// e.g., "a1b2c3d4e5f6.jpg"

if !store.exists("thumbnails/\(filename)") {
    let data = try await downloadImage(from: url)
    try await store.save(data, to: "thumbnails/\(filename)")
}

FileStoreError

All file operations throw typed errors.

FileStoreError.swift
public enum FileStoreError: LocalizedError, Sendable {
    case fileNotFound(path: String)
    case fileAlreadyExists(path: String)
    case writeFailed(path: String, underlying: String)
    case deleteFailed(path: String, underlying: String)
    case directoryCreationFailed(path: String, underlying: String)
}
ErrorHandling.swift
do {
    try await store.save(data, to: "file.txt", overwrite: false)
} catch let error as FileStoreError {
    switch error {
    case .fileAlreadyExists(let path):
        print("Already exists: \(path)")
    case .writeFailed(let path, let underlying):
        print("Write failed at \(path): \(underlying)")
    default:
        print(error.localizedDescription)
    }
}

InMemoryFileStore

Dictionary-backed implementation for tests and previews. No disk I/O. Conforms to the same FileStoring protocol.

InMemoryFileStore.swift
let store = InMemoryFileStore()

// Use in tests
try await store.save(Data("test".utf8), to: "notes/test.txt")
#expect(store.exists("notes/test.txt"))

let data = try await store.load("notes/test.txt")
#expect(String(data: data, encoding: .utf8) == "test")

// Clean up
try await store.deleteAll()