Skip to content

Degrade a bundle from a newer version instead of refusing it whole - #95

Merged
vmillet-dev merged 1 commit into
mainfrom
fix/import-survives-a-newer-bundle
Sep 13, 2026
Merged

vmillet-dev merged 1 commit into
mainfrom
fix/import-survives-a-newer-bundle

Conversation

@vmillet-dev

@vmillet-dev vmillet-dev commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Closes #27.

transfer::model::Bundle deserialises Vec<Note> directly, and Note carries two closed enums. One note's "language": "rust" made serde fail, read_bundle returned ImportFormat, and the whole import went down — not the one note that carried it. FORMAT_VERSION exists precisely to handle this gracefully, but it only ever fired on the version field, and adding an enum variant does not bump it.

This has to land before #17, which adds rust, go, java and friends to Language.

The decision: degrade, and say so

The ticket asks for the choice between refusing, degrading and skipping to be made deliberately and written down. This takes degrading, for three reasons:

  1. It settles the inconsistency the ticket names. The database read has always degraded — notes::store, TryFrom<NoteRow>: row.language.parse().unwrap_or_default(), commented as "a newer version may have written a value this build does not know". The bundle read refused. A bundle is the same data through another door, so it now answers the same way.
  2. It loses less than skipping. A rust note imported as txt keeps its title, body, tags, source and deadline; only the colouring is dropped, and one click in the editor restores it. Skipping the note loses all of it.
  3. Bumping FORMAT_VERSION per variant (option 1) is the worst of the three for the user — 500 notes refused over one field — and it turns the version number into a changelog of enum edits.

The ticket's objection to degrading is that it is "lossy in a way the report does not mention". So the report mentions it: ImportReport.notes_degraded, and a message that names the cause.

What stays strict is the bridge. A language the front end cannot name is still a deserialisation failure at the IPC boundary — that is what lets the generated TypeScript union be trusted.

How

read_bundle walks the raw JSON before building the Bundle, and replaces any language or kind whose string this build cannot FromStr with the default, collecting the ids it touched. It returns an IncomingBundle { bundle, degraded }.

  • The version check moved ahead of the parse, off the raw JSON. A file from a genuinely future format may not deserialise at all, and the designed message beats serde's.
  • A field that is absent, or that holds something other than a string, is left for serde to judge: a malformed file is malformed, not a file from a newer version.
  • degrade_field is generic over FromStr + Default + Display, so no variant spelling is written twice — closed_enum! already guarantees the single spelling.
  • merge counts a degraded note only when it actually inserted it. Re-importing the same file imports nothing, so it reports nothing degraded either — otherwise the warning would repeat itself forever on a file the user already has.

What the user sees

One status line, since StatusNotifier holds one message at a time. A third key joins file.imported and file.importedNothing:

5 note(s) imported from library.json, 0 skipped. 1 came from a newer version: their language or kind was brought down to what this version can read.

In both locales.

Tests

  • Rust unit (transfer::model): an unknown language and an unknown kind each degrade and are listed; a known value is untouched; a non-string language is still a format error; the version guard now answers on a file whose notes this build could not have parsed.
  • Rust integration (tests/transfer.rs): a bundle whose first note is "rust" imports both notes with notes_degraded: 1 and the language back at the default; same for an unknown kind; and a second import of the same file reports 0 degraded.
  • IPC contract: notesDegraded crosses as camelCase.
  • Front-end: the store picks the new key and passes the count.
  • e2e (10-library-transfer): a bundle written from Node with "language": "rust" imports through the real bridge, reports one degraded, and the note comes back as txt.

Checked

  • cargo test — 165 + 34 + 83 + 14 + 10 passing. cargo clippy --all-targets -- -D warnings and cargo fmt --check — clean.
  • npm test — 917 passing. npm run lint — clean.
  • e2e 01 and 10 — green.

docs/architecture.md and CLAUDE.md record the stance and the ⚠️ that an added enum variant does not bump FORMAT_VERSION.

Also in here: the comment on the unicode-normalization dependency line from #16 is dropped — it merged before that follow-up push landed.

`Note` carries two closed enums, so one note's `"language": "rust"` made
serde fail and took the other 499 with it. `read_bundle` now walks the
raw JSON first and brings any value this build cannot name down to the
default, counting the notes it touched; `ImportReport.notes_degraded`
reports them, counted on what was actually inserted so a re-import says
nothing a second time.

This is the stance the database read already took. The bridge stays
strict. An added enum variant is not a format break, so it does not bump
FORMAT_VERSION — which is now read off the raw JSON, before the bundle is
built, so a genuinely future format answers with its own message.
@vmillet-dev
vmillet-dev merged commit c1deaf9 into main Sep 13, 2026
10 of 11 checks passed
@vmillet-dev
vmillet-dev deleted the fix/import-survives-a-newer-bundle branch September 13, 2026 17:54
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.

An export from a newer version fails to import entirely

1 participant