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.automergedocument. - Persist records into per-month documents under
records/YYYY/MM.automerge. - Cache loaded
Documentobjects 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
- File storage service — the protocol this actor implements.
- Automerge storage architecture — on-disk format and CRDT design.
- Cross-process safety — flock, mtime invalidation, and the app/CLI contract.
- Conflict resolution — merging sync-service conflict copies.
- External change monitor — the app-side poller that drives reloads.