Skip to content

feat: add durable notes persistence#66

Merged
Gabko14 merged 7 commits intomainfrom
feature/durable-notes-persistence
Mar 1, 2026
Merged

feat: add durable notes persistence#66
Gabko14 merged 7 commits intomainfrom
feature/durable-notes-persistence

Conversation

@Gabko14
Copy link
Owner

@Gabko14 Gabko14 commented Mar 1, 2026

Summary

  • move note persistence out of UserDefaults into a dedicated SQLite-backed NotesStore
  • separate editor UI state from persistence with a PersistenceCoordinator, recovery states, and lifecycle-driven flushes
  • add migration, backup, quarantine, and recovery flows plus regression coverage for durability edge cases

Testing

  • xcodebuild test -project Ghostly.xcodeproj -scheme Ghostly -destination 'platform=macOS' -only-testing:GhostlyTests CODE_SIGN_IDENTITY='' CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO

Copy link

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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: TabManager

If 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-only to files.user-selected.read-write for recovery bundle export. This is necessary but worth documenting.

  • Test SQL uses string interpolation (NotesStoreTests.swift:2045): While UUID.uuidString is safe, using parameterized queries even in tests models better practices.

Positives

  • SQLite WAL mode for better write performance and concurrency
  • Comprehensive migration from legacy UserDefaults with 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).

@Gabko14
Copy link
Owner Author

Gabko14 commented Mar 1, 2026

Addressed the review feedback in c55ff44.

  • removed the hidden PersistenceCoordinator.shared dependency and injected the coordinator into AppLifecycleDelegate
  • made PersistenceCoordinator hold TabManager strongly instead of unowned
  • dropped the unique sort_index index in favor of a plain index so batched tab reordering cannot fail on transient index collisions
  • added regression coverage for single-pass tab reordering in NotesStoreTests

The atomic backup replacement concern was already fixed earlier in this branch.

Copy link

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 actor for NotesStore is the right choice for SQLite access isolation
  • The revision-based dirty tracking in TabManager is clever and prevents stale writes

Durability & Recovery

  • Proper WAL mode and SYNCHRONOUS = NORMAL for SQLite - good balance of durability and performance
  • Quarantine mechanism preserves corrupted data for user recovery
  • Backup snapshots and migration backups provide multiple recovery paths
  • AppLifecycleDelegate properly handles termination with terminateLater pattern

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...")
return

After 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-only to read-write makes sense for SQLite file access
  • Good handling of legacy text key migration for very old app versions
  • The normalizeSortIndexes pass on load is a nice self-healing mechanism

Questions

  1. Is there a maximum number of quarantine folders that can accumulate? Old quarantine data could build up over time.
  2. 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.

@Gabko14
Copy link
Owner Author

Gabko14 commented Mar 1, 2026

Follow-up cleanup is now in 628d845.

  • cancelled coordinator tasks in deinit
  • made the termination timeout an initializer parameter instead of a hardcoded sleep
  • changed the autosave task to avoid strongly re-capturing the coordinator after wakeup
  • documented the SQLITE_TRANSIENT binding sentinel

I left the DROP INDEX + recreate sequence in place intentionally because this branch migrates tabs_sort_index_idx from a unique index to a plain lookup index on existing stores.

Copy link

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Follow-up Review

Verified that all feedback from previous reviews has been addressed in the latest commits:

Confirmed Fixes:

  • deinit now cancels autosaveTask and currentFlushTask for 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_TRANSIENT binding is documented with a clear comment
  • Backup replacement uses atomic FileManager.replaceItemAt()
  • PersistenceCoordinator is injected into AppLifecycleDelegate (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.

@Gabko14 Gabko14 merged commit dce76f2 into main Mar 1, 2026
3 checks passed
@Gabko14 Gabko14 deleted the feature/durable-notes-persistence branch March 1, 2026 16:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant