AutomergeStorageService

The primary on-disk storage backend. An actor conforming to FileStorageServiceProtocol that reads and writes tags and time records as binary Automerge CRDT documents.

Source: Shared/Sources/MinutaShared/Services/AutomergeStorageService.swift.

Responsibilities

  • Persist tags to a single tags.automerge document.
  • Persist records into per-month documents under records/YYYY/MM.automerge.
  • Cache loaded Document objects in memory, invalidated by on-disk mtime.
  • Store and load image attachments alongside their record’s month directory.
  • Expose a read-only probe (hasExternalChanges()) for external writers.

It implements the same FileStorageServiceProtocol as the legacy JSON backend, so callers (AppState, the CLI, services) are agnostic to the format. See File storage service.

On-disk layout

<storage>/
  tags.automerge                  # one Automerge doc, keyed by tag UUID
  records/
    2026/
      04.automerge                # one Automerge doc per month, keyed by record UUID
      05.automerge
    records/2026/04/<filename>    # image files, alongside their month

Each Document is a CRDT map at .ROOT. Tag keys are tag UUID strings; record keys are record UUID strings. The wrappers AutomergeTag and AutomergeTimeRecord translate between the Swift models and the Automerge object graph.

For the format rationale and CRDT semantics, see Automerge storage architecture.

mtime-based cache

Loaded documents are cached, but never trusted blindly. Each load compares the file’s current modification date against the mtime observed when the document was last read:

private func loadTagsDocument() throws -> Document {
    let url = tagsFileURL
    let currentMtime = fileMtime(url)

    if let cached = tagsDocument, tagsDocumentMtime == currentMtime {
        return cached
    }
    // ... otherwise re-read from disk and record the new mtime
}

This means a write by another process (the CLI, or a sync service dropping a new file) is picked up on the next load without an explicit cache flush. Record documents use the same scheme, keyed by "YYYY-MM".

Record documents are held in an LRU-style cache capped at maxCachedDocuments (12 months); evictOldDocumentsIfNeeded() drops the oldest keys when the cap is reached.

Writes

Mutating operations (saveTags, mutateTags, saveRecord, updateRecord, deleteRecord) take the cross-process write lock before touching disk:

try StorageFileLock.withExclusiveLock(at: baseURL) {
    // read-mutate-write under flock
}

The write path is read-mutate-write: load the current document (honoring mtime so it reflects the latest on-disk state), apply the change, commit with a timestamp, and atomically overwrite the file (.write(to:options: .atomic)). The combination of the flock and mtime invalidation — not an in-process merge — is what keeps two writers from clobbering each other. saveTags and mutateTags explicitly clear the cached tags document first so a stale merged cache can’t reintroduce keys another writer deleted.

Tag saves diff against the on-disk key set and delete keys that are no longer present, so removals propagate as Automerge deletes rather than orphaned entries.

External change detection

hasExternalChanges() is a read-only probe used by the app’s ExternalChangeMonitor. It compares on-disk mtimes against the last-observed mtimes and returns true when any cached file changed, a cached file vanished, or a new month file appeared. It performs only stat-level I/O and mutates no state, so it is cheap to call on a short interval.

invalidateCache() drops all cached documents and mtimes; callers reload through the normal load methods afterward.

Related