Conversation
There was a problem hiding this comment.
Code Review: feat: add durable notes persistence
This is a well-architected PR that migrates note persistence from UserDefaults to SQLite with proper recovery mechanisms. The separation of concerns between NotesStore, PersistenceCoordinator, and TabManager is clean, and the actor isolation for NotesStore is appropriate.
Concerns
1. Backup file replacement is not atomic (NotesStore.swift:924-932)
if fileManager.fileExists(atPath: latestBackupURL.path) {
try fileManager.removeItem(at: latestBackupURL)
}
try fileManager.moveItem(at: tempURL, to: latestBackupURL)There's a window where the backup doesn't exist (after remove, before move). A crash here loses the backup. Use FileManager.replaceItemAt(_:withItemAt:) for atomic replacement:
_ = try fileManager.replaceItemAt(latestBackupURL, withItemAt: tempURL)2. unowned reference could cause crashes (PersistenceCoordinator.swift:120)
private unowned let tabManager: TabManagerIf TabManager is deallocated before PersistenceCoordinator, this crashes. Consider using weak with proper nil handling, or ensure the ownership chain guarantees TabManager outlives PersistenceCoordinator.
3. Static shared instance is a hidden dependency (PersistenceCoordinator.swift:112)
static var shared: PersistenceCoordinator?AppLifecycleDelegate accesses PersistenceCoordinator.shared as a hidden dependency. This works but makes testing harder and hides the dependency graph. Consider passing the coordinator explicitly or using environment objects.
4. UNIQUE INDEX on sort_index may cause incremental save failures
CREATE UNIQUE INDEX IF NOT EXISTS tabs_sort_index_idx ON tabs(sort_index);The full-snapshot write deletes all tabs first, so it works. But the incremental save() method upserts individual tabs, which could violate the unique constraint during tab reordering if two tabs temporarily share the same sort_index. This may not happen with current code paths but is fragile.
Minor Items
-
Entitlement escalation: Changed from
files.user-selected.read-onlytofiles.user-selected.read-writefor recovery bundle export. This is necessary but worth documenting. -
Test SQL uses string interpolation (NotesStoreTests.swift:2045): While
UUID.uuidStringis safe, using parameterized queries even in tests models better practices.
Positives
- SQLite WAL mode for better write performance and concurrency
- Comprehensive migration from legacy
UserDefaultswith backup - Quarantine flow preserves corrupted data for debugging
- Recovery UI gives users clear options
- Good test coverage for the new persistence layer
- Clean separation between persistence models and UI models
Overall this is solid work. The main items to address are the non-atomic backup replacement (#1) and the unowned reference (#2).
|
Addressed the review feedback in
The atomic backup replacement concern was already fixed earlier in this branch. |
There was a problem hiding this comment.
Review: feat: add durable notes persistence
This is a well-architected change that moves note persistence from UserDefaults to SQLite. Overall, the implementation is solid with good separation of concerns.
Strengths
Architecture
- Clean separation with
NotesStore(actor for thread-safe SQLite access),PersistenceCoordinator(orchestrates save timing), and clear persistence models - Using
actorforNotesStoreis the right choice for SQLite access isolation - The revision-based dirty tracking in
TabManageris clever and prevents stale writes
Durability & Recovery
- Proper WAL mode and
SYNCHRONOUS = NORMALfor SQLite - good balance of durability and performance - Quarantine mechanism preserves corrupted data for user recovery
- Backup snapshots and migration backups provide multiple recovery paths
AppLifecycleDelegateproperly handles termination withterminateLaterpattern
Testing
- Comprehensive test coverage for NotesStore including edge cases (schema corruption, legacy migration, quarantine)
- Good use of dependency injection (now closure, UserDefaults, FileManager) for testability
Issues
1. Potential memory leak in autosave Task (PersistenceCoordinator.swift:48-53)
autosaveTask = Task { [weak self] in
guard let self else { return }
try? await Task.sleep(for: self.autosaveDelay)
guard !Task.isCancelled else { return }
await self.flushNow(reason: .typingIdle) // captures self strongly here
}After the guard let self, self is strongly captured for the rest of the closure. If autosave is scheduled repeatedly, cancelled tasks might briefly retain PersistenceCoordinator. This is likely fine in practice but worth noting.
2. Missing deinit or cleanup (PersistenceCoordinator.swift)
Consider cancelling autosaveTask and currentFlushTask on deinit to ensure clean shutdown.
3. Termination timeout hardcoded (PersistenceCoordinator.swift:124)
try? await Task.sleep(for: .seconds(2))The 2-second timeout is reasonable but might want to be configurable for testing, or documented as a known limit.
4. Error swallowed silently (PersistenceCoordinator.swift:94)
self.logger.error("Failed to flush notes...")
returnAfter a flush failure, the coordinator returns without retrying or notifying the user. For critical data, consider:
- A retry mechanism with backoff
- Surfacing persistent failures to the UI
5. transientDestructor usage (NotesStore.swift:692)
private let transientDestructor = unsafeBitCast(-1, to: sqlite3_destructor_type.self)This is correct for SQLITE_TRANSIENT, but consider adding a comment explaining this magic constant for future maintainers.
6. Index drop/create on every schema init (NotesStore.swift:384-385)
DROP INDEX IF EXISTS tabs_sort_index_idx;
CREATE INDEX IF NOT EXISTS tabs_sort_index_idx ON tabs(sort_index);The DROP then CREATE IF NOT EXISTS on every open is redundant since the index definition has not changed. The CREATE IF NOT EXISTS alone would suffice.
Minor Observations
- Entitlements change from
user-selected.read-onlytoread-writemakes sense for SQLite file access - Good handling of legacy
textkey migration for very old app versions - The
normalizeSortIndexespass on load is a nice self-healing mechanism
Questions
- Is there a maximum number of quarantine folders that can accumulate? Old quarantine data could build up over time.
- Should the app surface recovery options in a more discoverable way (e.g., Settings menu) rather than only on error?
Overall this is a quality implementation. The issues above are mostly minor refinements rather than blockers.
|
Follow-up cleanup is now in
I left the |
There was a problem hiding this comment.
Follow-up Review
Verified that all feedback from previous reviews has been addressed in the latest commits:
Confirmed Fixes:
deinitnow cancelsautosaveTaskandcurrentFlushTaskfor clean shutdown- Termination timeout is now an initializer parameter instead of hardcoded
- Autosave task uses optional chaining (
self?.flushNow) to avoid strong capture after wakeup SQLITE_TRANSIENTbinding is documented with a clear comment- Backup replacement uses atomic
FileManager.replaceItemAt() PersistenceCoordinatoris injected intoAppLifecycleDelegate(no hidden static)- Unique index dropped in favor of plain index for tab reordering
The PR is ready for merge. The architecture is solid with clean separation between NotesStore (actor-isolated SQLite), PersistenceCoordinator (flush orchestration), and TabManager (UI state with dirty tracking). Test coverage is comprehensive.
Summary
UserDefaultsinto a dedicated SQLite-backedNotesStorePersistenceCoordinator, recovery states, and lifecycle-driven flushesTesting