Skip to content

Conversation

@devin-ai-integration
Copy link
Contributor

@devin-ai-integration devin-ai-integration bot commented Jan 27, 2026

Summary

This PR moves the SQLite data migration logic from TypeScript (local persister) to Rust (fs-db plugin) as an explicit migration step. Previously, the migration happened in TypeScript during app load, which was vulnerable to data loss if the user force-quit during first launch. Now the migration runs atomically in Rust before TypeScript loads.

Key changes:

  • New migration v1_0_4_migrate_sqlite_to_filesystem.rs that reads from SQLite using the existing v1_sqlite importer and writes to the filesystem format
  • Simplified TypeScript local persister - removed SQLite loading logic and migrateWordsAndHintsToTranscripts function
  • Made sources module public in importer plugin to allow reuse of import_all_from_path

The migration is idempotent: it only runs if db.sqlite exists AND no session data is present in the filesystem.

Review & Testing Checklist for Human

  • CRITICAL: Verify migration version chain - The new migration is FROM v1.0.3 TO v1.0.4. Confirm this aligns with the intended release version (user mentioned v1.0.2). The migration won't run unless the vault is detected as v1.0.3.
  • Test with real v1.0.1 SQLite data - Manually test the migration with actual user data to verify sessions, transcripts, humans, organizations, templates, and enhanced notes are correctly migrated
  • Verify filesystem format compatibility - Ensure the generated _meta.json, transcript.json, _memo.md, and enhanced note files match the format expected by the session persister
  • Test partial failure scenario - What happens if the app crashes mid-migration? The idempotency check looks for any _meta.json in sessions dir, but partial writes could leave data in an inconsistent state
  • Verify YAML frontmatter - The custom json_to_yaml function may not handle all edge cases (nested objects, special characters in strings)

Recommended test plan:

  1. Take a backup of a v1.0.1 data directory with db.sqlite
  2. Run the app with this PR and verify all data appears correctly
  3. Force-quit during migration and verify the app recovers gracefully on next launch

Notes

  • The LocalPersisterLoaded flag and associated Rust commands are now unused but not removed (can be cleaned up in a follow-up PR)
  • No unit tests were added for the migration code

Link to Devin run: https://app.devin.ai/sessions/8a49ec2d87984040997331253f3a3973
Requested by: @yujonglee


Open with Devin

- Add new migration v1_0_4_migrate_sqlite_to_filesystem.rs that reads from
  SQLite and writes to filesystem format during app startup
- Remove SQLite loading logic from TypeScript local persister
- Make sources module public in importer plugin for reuse
- Migration is idempotent and only runs if db.sqlite exists and no
  session data is present in filesystem

This makes the migration atomic and safe from force-quit issues during
first launch, as the migration now happens in Rust before TypeScript loads.

Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
@netlify
Copy link

netlify bot commented Jan 27, 2026

Deploy Preview for hyprnote-storybook canceled.

Name Link
🔨 Latest commit ddfd9d5
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote-storybook/deploys/69788de46160330008caf6d0

@netlify
Copy link

netlify bot commented Jan 27, 2026

Deploy Preview for hyprnote canceled.

Name Link
🔨 Latest commit ddfd9d5
🔍 Latest deploy log https://app.netlify.com/projects/hyprnote/deploys/69788de4831a4b0008251645

@devin-ai-integration
Copy link
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR that start with 'DevinAI' or '@devin'.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

Comment on lines +16 to +25
pub fn run(base_dir: &Path) -> Result<()> {
let sqlite_file = base_dir.join("db.sqlite");
if !sqlite_file.exists() {
return Ok(());
}

let sessions_dir = base_dir.join("sessions");
if sessions_dir.exists() && has_session_data(&sessions_dir) {
return Ok(());
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Critical: Incomplete migration handling leads to data loss

The idempotency check has a race condition that can cause incomplete migrations. If the migration is interrupted after creating some sessions (lines 194-241), the next run will:

  1. Find db.sqlite exists (line 18) ✓
  2. Find sessions_dir exists with _meta.json files (line 23) ✓
  3. Skip the entire migration (line 24)
  4. Leave unmigrated sessions in SQLite permanently

The issue is that has_session_data() returns true if ANY session has a _meta.json, but the per-session skip at line 197-199 only prevents re-creating existing sessions. Interrupted migrations leave some data in SQLite and some on filesystem, with no way to complete the migration.

Fix: The migration should track completion state explicitly:

pub fn run(base_dir: &Path) -> Result<()> {
    let sqlite_file = base_dir.join("db.sqlite");
    if !sqlite_file.exists() {
        return Ok(());
    }

    // Check for migration completion marker instead
    let migration_complete = base_dir.join(".migration_v1_0_4_complete");
    if migration_complete.exists() {
        return Ok(());
    }

    let runtime = tokio::runtime::Runtime::new().map_err(|e| crate::Error::Io(e.into()))?;
    runtime.block_on(async { migrate_sqlite_to_filesystem(base_dir, &sqlite_file).await })?;
    
    // Mark migration as complete
    std::fs::write(&migration_complete, "")?;
    Ok(())
}
Suggested change
pub fn run(base_dir: &Path) -> Result<()> {
let sqlite_file = base_dir.join("db.sqlite");
if !sqlite_file.exists() {
return Ok(());
}
let sessions_dir = base_dir.join("sessions");
if sessions_dir.exists() && has_session_data(&sessions_dir) {
return Ok(());
}
pub fn run(base_dir: &Path) -> Result<()> {
let sqlite_file = base_dir.join("db.sqlite");
if !sqlite_file.exists() {
return Ok(());
}
// Check for migration completion marker instead
let migration_complete = base_dir.join(".migration_v1_0_4_complete");
if migration_complete.exists() {
return Ok(());
}
let runtime = tokio::runtime::Runtime::new().map_err(|e| crate::Error::Io(e.into()))?;
runtime.block_on(async { migrate_sqlite_to_filesystem(base_dir, &sqlite_file).await })?;
// Mark migration as complete
std::fs::write(&migration_complete, "")?;
Ok(())
}

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.

Copy link
Contributor Author

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View issue and 5 additional flags in Devin Review.

Open in Devin Review

Comment on lines +527 to +529
fn escape_yaml_string(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

🔴 YAML escape function doesn't handle newlines, causing malformed frontmatter

The escape_yaml_string function only escapes backslashes and double quotes, but doesn't handle newline characters (\n), carriage returns (\r), or other control characters.

Click to expand

How it triggers

When migrating data from SQLite to filesystem, if any field like title, name, content, or event_id contains a newline character, the generated YAML frontmatter will be malformed:

fn escape_yaml_string(s: &str) -> String {
    s.replace('\\', "\\\\").replace('"', "\\\"")
}

For example, if a session title is "Meeting\nNotes", it will produce:

title: "Meeting
Notes"

Instead of the valid:

title: "Meeting\nNotes"

Impact

The malformed YAML will cause parsing failures when the app tries to read the migrated data files (_meta.json, human markdown files, organization markdown files), potentially causing data to be inaccessible or the app to crash on load.

Recommendation: Extend the escape function to handle newlines and other control characters:

fn escape_yaml_string(s: &str) -> String {
    s.replace('\\', "\\\\")
     .replace('"', "\\\"")
     .replace('\n', "\\n")
     .replace('\r', "\\r")
     .replace('\t', "\\t")
}
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

devin-ai-integration bot and others added 3 commits January 27, 2026 09:38
Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
Copy link
Contributor Author

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

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

Devin Review found 1 new potential issue.

View issue and 7 additional flags in Devin Review.

Open in Devin Review

Comment on lines +22 to +25
let sessions_dir = base_dir.join("sessions");
if sessions_dir.exists() && has_session_data(&sessions_dir) {
return Ok(());
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

🔴 Incomplete migration on interruption due to flawed idempotency check

The migration's idempotency check can skip migrating remaining sessions if the app crashes mid-migration.

Click to expand

Mechanism

The migration at lines 22-25 checks if ANY session has _meta.json before running:

let sessions_dir = base_dir.join("sessions");
if sessions_dir.exists() && has_session_data(&sessions_dir) {
    return Ok(());
}

But has_session_data() returns true if ANY single session folder contains _meta.json.

Scenario

  1. Migration starts with sessions A, B, C in SQLite
  2. Session A is fully written to filesystem (_meta.json created)
  3. App crashes/force-quit before sessions B and C are migrated
  4. On restart, has_session_data() returns true (A has _meta.json)
  5. Entire migration is skipped
  6. Sessions B and C remain only in SQLite and are never migrated

Impact

Data loss - sessions that weren't migrated before the crash will never be migrated to the filesystem format, even though SQLite still contains them.

Expected behavior

The migration should check per-session whether migration is needed, or track which sessions have been successfully migrated.

Recommendation: Instead of checking for ANY session data, compare the sessions in SQLite against those already migrated to filesystem. Or remove the early return and rely on the per-session if session_dir.exists() { continue; } check at line 197-199, which already provides per-session idempotency.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Co-Authored-By: yujonglee <yujonglee.dev@gmail.com>
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