Skip to content

file_path lookups are byte-wise, so NFC/NFD filename variants create duplicate entities (macOS + Syncthing) #1275

Description

@SloNN

Environment: basic-memory 0.22.1, macOS (APFS), Python 3.14, project synced with Linux peers via Syncthing.

What happens

On macOS the same filename can exist on disk in either NFC or NFD form — APFS is normalization-preserving, so whichever form was used at write time is what is stored. When a project is synced across platforms, the form flips: Syncthing enforces NFD on macOS and NFC everywhere else. That is documented, intended behaviour, and a request to make the normalization user-selectable was declined in syncthing/syncthing#5339"This seems super duper niche and honestly I don't think it's something we want to dive into". So for any cross-platform synced vault, filename form on macOS is outside the user's control.

EntityRepository compares file_path byte-wise:

query = self.select().where(Entity.file_path == Path(file_path).as_posix())

A lookup issued in one normalization form does not match a row stored in the other. The sync layer then treats an already-indexed file as new and creates a second entity for the same file, whose permalink gets a -1 suffix from the collision counter in entity_service.py.

Symptoms

  • read_note by title returns two candidates for one file; search results contain duplicates.
  • edit_note on an affected note fails with sqlite3.IntegrityError: UNIQUE constraint failed: entity.permalink, entity.project_id — the update tries to claim a permalink already owned by the twin row. The note becomes uneditable via MCP.
  • Permalinks accumulate -1, -1-1 suffixes permanently, which breaks any external tracking keyed by permalink.
  • The duplicates are invisible to a plain GROUP BY file_path — the strings differ byte-wise. They only show up when grouping by unicodedata.normalize('NFC', file_path).

In one vault of 832 notes this produced 34 duplicate pairs over two weeks. reindex --full --search collapses them (it rebuilds entity rows from the filesystem), but they come back as soon as filenames drift again.

Reproduce

  1. write_note with a non-ASCII title, e.g. Проверка нормализации. The file lands as NFC.
  2. Rename the file to its NFD form. Note: a direct os.rename(nfd, nfc) is a no-op on APFS — you must go through an intermediate ASCII name.
  3. Let the watcher sync. A second entity appears with a -1 permalink.

Note: the codebase already has utils.normalize_file_path_for_comparison(), but it is only used by detect_potential_file_conflicts() — not on the lookup path.

Suggested fix

Match against every normalization form rather than the raw string:

def _file_path_variants(file_path: Union[Path, str]) -> List[str]:
    posix = Path(file_path).as_posix()
    variants = [posix]
    for form in ("NFC", "NFD"):
        candidate = unicodedata.normalize(form, posix)
        if candidate not in variants:
            variants.append(candidate)
    return variants

applied in get_by_file_path, the permalink-by-path lookup, get_by_file_paths (used by sync change detection) and delete_by_file_path.

I am running this as a local patch. Verified end-to-end: created a note (NFC filename), renamed the file to NFD, let the watcher sync — the entity count stayed at 1, the permalink kept no suffix, edit_note no longer raised IntegrityError, and delete_note removed the NFD file correctly. Duplicates across the database went to 0.

A stricter alternative is to normalize file_path to NFC on write and migrate existing rows, but that needs a migration; matching both forms is backward-compatible.

Happy to open a PR if the approach looks right.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions