FileStorageService

Protocol

public protocol FileStorageService: Sendable {
    func loadTags() async throws -> [Tag]
    func saveTags(_ tags: [Tag]) async throws
    func loadRecords(from: Date, to: Date) async throws -> [TimeRecord]
    func loadRunningRecords() async throws -> [TimeRecord]
    func loadAllRecords() async throws -> [TimeRecord]
    func saveRecord(_ record: TimeRecord) async throws
    func updateRecord(_ record: TimeRecord) async throws
    func deleteRecord(_ record: TimeRecord) async throws

    // Image operations
    func saveImage(_ data: Data, for record: TimeRecord, filename: String) async throws
    func loadImage(filename: String, for record: TimeRecord) async throws -> Data
    func deleteImage(filename: String, for record: TimeRecord) async throws
    func imageURL(filename: String, for record: TimeRecord) -> URL
}

Implementation: LocalFileStorageService

Actor-based implementation using local filesystem at ~/Documents/Minuta/.

public actor LocalFileStorageService: FileStorageService {
    private let baseURL: URL
    private var recordsCache: [UUID: TimeRecord] = [:]
    private var cachePopulated: Bool = false

    // ...
}

Directory Management

  • Creates records/YYYY/MM/ directories as needed
  • Tags stored in single tags.json file
  • Records stored as individual JSON files

Record Caching

The service maintains an in-memory cache for performance:

Cache Behavior

  • First Load: Cache populated when loadRecords() or loadRunningRecords() is first called
  • Queries: Subsequent queries filter from cache instead of disk
  • Mutations: Cache invalidated on saveRecord(), updateRecord(), deleteRecord()

Cache Invalidation

public func invalidateCache() {
    recordsCache.removeAll()
    cachePopulated = false
}

Benefits

  • Reduces disk I/O for repeated queries (e.g., filtering by date range)
  • Actor isolation ensures thread-safe cache access
  • Automatic invalidation maintains consistency

Image Storage

Images are stored alongside records in the same directory structure:

  • Path: records/YYYY/MM/{record-id}_{index}.{ext}
  • Images are associated with records via TimeRecord.images array (filenames only)
  • Automatic deletion when parent record is deleted

Error Handling

public enum FileStorageError: Error, LocalizedError {
    case directoryCreationFailed(Error)
    case fileWriteFailed(Error)
    case fileReadFailed(Error)
    case fileNotFound(String)
    case decodingFailed(Error)
    case encodingFailed(Error)
    case imageWriteFailed(Error)
    case imageNotFound(String)
}

Test Coverage

55 tests covering:

  • Directory creation
  • Tags persistence (save/load/missing file/corrupt JSON)
  • Record persistence (save/load/update/delete)
  • Error handling (all FileStorageError cases)
  • Edge cases (special chars, unicode, long comments)
  • Date range queries
  • Running records
  • Concurrent access
  • Cache behavior

Related

Models

Services

UI

Architecture