Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ These are the non-obvious constraints; the rest of the architecture is in `docs/
- **A modal is a shell, not a copy.** `DialogComponent` (`shared/dialog/`) owns the scrim, the panel, `role="dialog"`, `aria-modal`, the focus trap, Escape and the backdrop click; a dialog projects its content and declares a `layer`. The rung table in `dialog.model.ts` is the **only** place a modal `z-index` exists — its index in `LAYERS` is both the `z-index` and the Escape priority. `DialogStack` gives the keystroke to whichever modal is in front, ordering by rung and not by arrival (the fields form is created after the palette but drawn over it), and its `hasOpenDialog()` is what tells the canvas its keyboard is taken. What a shell cannot guess — width, height, gap, padding, where it sits on the scrim — comes from `--dialog-*` custom properties the consumer sets on the `app-dialog` element; no measurement goes through an `input()` or gets spelled in a template.
- **⚠️ A command that touches the database or the disk is `#[tauri::command(async)]`.** A plain `#[tauri::command]` compiles as `ExecutionContext::Blocking` and runs its body **inline in the WebView's IPC handler** — the main thread — freezing the window for its whole duration. `(async)` on the same synchronous function moves it off that thread; nothing about the signature or `bindings.ts` changes. ⚠️ It lands on a **Tokio worker**, not on `spawn_blocking` — the macro emits `respond_async_serialized`, which is `async_runtime::spawn` — so the pool is the size of the core count and is shared with the updater and the plugins. Fine while the connection mutex serializes the database work anyway; a genuinely long command would need `async fn` plus an explicit `spawn_blocking`. The exception is `desktop.rs`: the tray and shortcut commands want the main thread, which is also why `desktop::init` manages every piece of native state up front rather than letting a command create its own.
- **A module holding commands is `pub`; everything else is `pub(crate)`.** `#[specta::specta]` generates a macro per command that `collect_commands!` resolves from the crate root, so those modules cannot be narrowed. Narrowing the rest is what gives `dead_code` and `unreachable_pub` — both denied in `Cargo.toml` — something to say: a blanket `pub` had silenced them, and three unused items had accumulated behind it.
- **A bundle from a newer version degrades rather than failing whole.** `Note` carries two closed enums, so one note's `"language": "rust"` used to fail the import of the other 499. `transfer::model::read_bundle` walks the raw JSON first, brings any value this build cannot name down to the default and counts the notes it touched; `ImportReport.notes_degraded` says how many, counted on what was actually inserted so a re-import reports nothing. This matches the **database** read, which has always degraded — the bridge is what stays strict. ⚠️ An added enum variant therefore does **not** bump `FORMAT_VERSION`: the file still parses.
- **The export file is staged then renamed, and an import is one transaction.** `fs::write` truncates first, so writing straight to the target destroyed the previous export when the disk filled; and a per-note transaction left half an import behind with the report lost. Both are in `transfer/file.rs` and `transfer/bundle.rs` — `transfer.rs` holds commands only.
- **Zoneless.** Every component is `OnPush` and state is signal-based; derived state is `computed()`, never a manually maintained signal.
- **Three stores hold the canvas, and the dependency runs one way.** `NotesQueryStore` answers _which notes are shown_ (search, filters, facets, the `resource`, the retained view); `NoteSelectionStore` answers _which one is pointed at_ (focus and ticks, both positions in `visibleNotes`); `NotesStore` answers _the note itself_ (open note, draft, writes, deletion, undo). The page injects them as `canvas`, `selection` and `store`.
Expand Down
20 changes: 20 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,26 @@ Both fields carry `#[serde(default)]`. `transfer::Bundle` deserialises `Note` it
required key would have made every export file written before todo lists unreadable —
`FORMAT_VERSION` stays at 1 precisely because old files still read.

**A bundle from a newer version degrades, it does not fail.** `Note` carries two closed
enums, `language` and `kind`, so a file written once `Language` has grown a `rust` variant
used to fail serde outright and take the other 499 notes 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, and `merge` counts only
those actually inserted, so re-importing the same file says nothing a second time.

That settles an inconsistency the two read paths had by accident. The **database** read has
always degraded (`notes::store`, `TryFrom<NoteRow>`: `row.language.parse().unwrap_or_default()`);
the bundle read refused. A bundle is the same data through another door, so it degrades too.
Degrading also loses less than skipping the note would: the title, body, tags and deadline
all arrive, only the colouring is dropped. What stays strict is the **bridge** — a `language`
the front end cannot name is still a deserialisation failure there, which is what lets the
generated union be trusted.

⚠️ Consequences for `FORMAT_VERSION`: an added enum variant **does not** bump it. It is not a
format break, since the file still parses. It is bumped when a file written today would stop
being readable — and it is read off the raw JSON before the bundle is built, so a genuinely
future format answers with the designed message rather than with a serde error about a field.

Progress (`done`/`total`) is **not** on the wire. The items already travel with the note, and
a counter beside them would be the identity mapper this codebase refuses elsewhere; the card
and the editor each count in a `computed()`. That is the same line as relative-time
Expand Down
37 changes: 36 additions & 1 deletion e2e/specs/10-library-transfer.e2e.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { browser, expect } from '@wdio/globals';
import { existsSync, mkdtempSync, readFileSync } from 'node:fs';
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

Expand Down Expand Up @@ -103,6 +103,41 @@ describe('Import, export and share', () => {
expect(view.sections[0]?.notes[0]?.spaceId).toBe(homeId);
});

/**
* `language` and `kind` are closed enums on both sides, so one note written by a
* newer DevBox used to fail the **whole** file on a serde message about a variant.
* It now arrives with that field brought down to the default, and the report is what
* says the colouring was lost.
*/
it('imports a bundle from a newer version instead of refusing it whole', async () => {
const source = JSON.parse(readFileSync(bundlePath, 'utf8')) as {
notes: Record<string, unknown>[];
};
const newerPath = join(directory, 'newer.json').replaceAll('\\', '/');
writeFileSync(
newerPath,
JSON.stringify({
...source,
notes: [
{
...source.notes[0],
id: 'written-by-a-newer-devbox',
title: 'Ahead of this build',
language: 'rust',
},
],
}),
'utf8',
);

const report = await bridge.importNotes(newerPath);
expect(report.notesImported).toBe(1);
expect(report.notesDegraded).toBe(1);

const view = await bridge.queryNotes(query({ search: 'Ahead of this build' }));
expect(view.sections[0]?.notes[0]?.language).toBe('txt');
});

it('greys out the menu entries that have nothing to act on', async () => {
await fileMenu.open();
// Nothing is ticked, so there is no selection to export. The entry stays in the DOM
Expand Down
3 changes: 0 additions & 3 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,9 +50,6 @@ tauri-plugin-clipboard-manager = "2"
tauri-plugin-log = "2"
log = "0.4"
thiserror = "2.0.20"
# Search folds accents, which means decomposing before dropping the marks: `etape`
# has to find `Étape` on a French corpus. Hand-rolling a Latin table instead would
# be a list that goes stale the first time someone writes Polish or Vietnamese.
unicode-normalization = "0.1.25"
# Pulled in only by the `e2e` feature, and not optional in practice: the runner
# waits on `window.wdioTauri` before it will open a session, so a binary without
Expand Down
11 changes: 9 additions & 2 deletions src-tauri/src/transfer/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use chrono::Utc;
use diesel::SqliteConnection;
use diesel::prelude::*;

use super::model::{self, Bundle, ImportReport};
use super::model::{self, Bundle, ImportReport, IncomingBundle};
use crate::error::StorageError;
use crate::notes::model::Note;
use crate::notes::store as notes;
Expand Down Expand Up @@ -41,8 +41,10 @@ pub fn collect(
/// created and part of the notes in, with the report lost along with the error.
pub fn merge(
connection: &mut SqliteConnection,
bundle: Bundle,
incoming: IncomingBundle,
) -> Result<ImportReport, StorageError> {
let IncomingBundle { bundle, degraded } = incoming;

connection.transaction(|connection| {
let mut report = ImportReport::default();

Expand Down Expand Up @@ -74,6 +76,11 @@ pub fn merge(

if notes::insert_imported(connection, &note)? {
report.notes_imported += 1;
// Only what actually came in: re-importing the same file imports
// nothing, and would otherwise keep reporting the same degradation.
if degraded.contains(&note.id) {
report.notes_degraded += 1;
}
} else {
report.notes_skipped += 1;
}
Expand Down
8 changes: 4 additions & 4 deletions src-tauri/src/transfer/file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};

use uuid::Uuid;

use super::model::{Bundle, ExportReport};
use super::model::{Bundle, ExportReport, IncomingBundle};
use crate::count::saturating_u32;
use crate::error::{AppError, StorageError};

Expand All @@ -32,7 +32,7 @@ pub fn write(path: &str, bundle: &Bundle) -> Result<ExportReport, AppError> {
Ok(report)
}

pub fn read(path: &str) -> Result<Bundle, AppError> {
pub fn read(path: &str) -> Result<IncomingBundle, AppError> {
let json = std::fs::read_to_string(path)
.map_err(|error| StorageError::File(format!("{path}: {error}")))?;

Expand Down Expand Up @@ -147,8 +147,8 @@ mod tests {
write(&target.to_string_lossy(), &bundle()).unwrap();
let read_back = read(&target.to_string_lossy()).unwrap();

assert_eq!(read_back.notes.len(), 1);
assert_eq!(read_back.spaces[0].name, "Personal");
assert_eq!(read_back.bundle.notes.len(), 1);
assert_eq!(read_back.bundle.spaces[0].name, "Personal");
std::fs::remove_dir_all(&directory).ok();
}
}
162 changes: 148 additions & 14 deletions src-tauri/src/transfer/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,27 @@
//! to `Note` is exported without anyone thinking about it, and an older file stays
//! readable as long as serde can fill the gap.

use std::collections::BTreeMap;
use std::fmt::Write;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt::{Display, Write};
use std::str::FromStr;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use specta::Type;

use crate::error::{StorageError, ValidationError};
use crate::notes::checklist::{self, NoteKind};
use crate::notes::language::Language;
use crate::notes::model::Note;
use crate::spaces::model::Space;

/// Bumped when a file written today would stop being readable. Refusing a newer
/// version beats importing half of it.
///
/// ⚠️ An **added enum variant does not bump this**. It is not a format break: the
/// file still parses, one field just names something this build has never heard of,
/// and [`read_bundle`] brings that field down to the default. Bumping here instead
/// would refuse a 500-note file over one note's `"rust"`.
pub const FORMAT_VERSION: u32 = 1;

#[derive(Debug, Clone, Serialize, Deserialize, Type)]
Expand All @@ -42,20 +49,94 @@ pub struct ImportReport {
pub spaces_created: u32,
pub notes_imported: u32,
pub notes_skipped: u32,
/// Imported, but with a `language` or a `kind` this build does not know brought
/// down to the default. Counted so the loss is said rather than discovered.
pub notes_degraded: u32,
}

pub fn read_bundle(json: &str) -> Result<Bundle, StorageError> {
let bundle: Bundle = serde_json::from_str(json)
/// A bundle read from a file, and the ids [`read_bundle`] had to bring down to a
/// shape this build knows.
#[derive(Debug)]
pub struct IncomingBundle {
pub bundle: Bundle,
pub degraded: BTreeSet<String>,
}

/// ⚠️ The version is read off the raw JSON, before the bundle is built: a file from
/// a future format may not deserialise at all, and the designed message beats serde's.
pub fn read_bundle(json: &str) -> Result<IncomingBundle, StorageError> {
let mut value: serde_json::Value = serde_json::from_str(json)
.map_err(|error| StorageError::ImportFormat(error.to_string()))?;

if bundle.version > FORMAT_VERSION {
let version = value
.get("version")
.and_then(serde_json::Value::as_u64)
.unwrap_or_default();

if version > u64::from(FORMAT_VERSION) {
return Err(StorageError::ImportFormat(format!(
"format version {}, this version of DevBox reads up to {FORMAT_VERSION}",
bundle.version
"format version {version}, this version of DevBox reads up to {FORMAT_VERSION}"
)));
}

Ok(bundle)
let degraded = degrade_unknown_values(&mut value);

let bundle: Bundle = serde_json::from_value(value)
.map_err(|error| StorageError::ImportFormat(error.to_string()))?;

Ok(IncomingBundle { bundle, degraded })
}

/// A newer DevBox may have written a `language` or a `kind` this build never heard of,
/// and `Note` deserialises both as closed enums — so one `"rust"` in a 500-note file
/// failed the whole import with a serde message about a variant.
///
/// **It degrades, like the database read already does** (`notes::store`,
/// `TryFrom<NoteRow>`): a bundle is the same data through another door, and that is the
/// one place the two disagreed. Degrading loses less than skipping the note — the title,
/// the body, the tags and the deadline all still arrive, only the colouring is dropped —
/// and the report says how many, so it is not silent. What stays strict is the
/// **bridge**: a value the front end cannot name has no business being written.
fn degrade_unknown_values(bundle: &mut serde_json::Value) -> BTreeSet<String> {
let mut degraded = BTreeSet::new();

let Some(notes) = bundle
.get_mut("notes")
.and_then(serde_json::Value::as_array_mut)
else {
return degraded;
};

for note in notes {
let language = degrade_field::<Language>(note, "language");
let kind = degrade_field::<NoteKind>(note, "kind");

if (language || kind)
&& let Some(id) = note.get("id").and_then(serde_json::Value::as_str)
{
degraded.insert(id.to_string());
}
}

degraded
}

/// A field that is absent, or holds something other than a string, is left for serde
/// to judge: a malformed file is malformed, not a file from a newer version.
fn degrade_field<T: FromStr + Default + Display>(
note: &mut serde_json::Value,
field: &str,
) -> bool {
let Some(current) = note.get(field).and_then(serde_json::Value::as_str) else {
return false;
};

if current.parse::<T>().is_ok() {
return false;
}

note[field] = serde_json::Value::String(T::default().to_string());
true
}

pub fn validate_path(path: &str) -> Result<(), ValidationError> {
Expand Down Expand Up @@ -194,17 +275,69 @@ mod tests {

#[test]
fn a_bundle_from_a_newer_version_is_refused_rather_than_half_read() {
// Notes this build could not deserialise at all: the version is read off the
// raw JSON first, so the answer names the version and not a serde field.
let json = serde_json::json!({
"version": FORMAT_VERSION + 1,
"exportedAt": "2026-07-25T09:00:00.000Z",
"spaces": [],
"notes": [],
"notes": [{ "shape": "from the future" }],
})
.to_string();

let error = read_bundle(&json).unwrap_err();
let StorageError::ImportFormat(message) = read_bundle(&json).unwrap_err() else {
panic!("expected a format error");
};

assert!(message.contains("format version"));
}

assert!(matches!(error, StorageError::ImportFormat(_)));
/// The file a newer DevBox writes once its `Language` has grown a variant.
fn bundle_with(field: &str, value: serde_json::Value) -> String {
let mut json = serde_json::json!({
"version": FORMAT_VERSION,
"exportedAt": "2026-07-25T09:00:00.000Z",
"spaces": [{ "id": "s-1", "name": "Personal" }],
"notes": [serde_json::to_value(sample()).unwrap()],
});
json["notes"][0][field] = value;

json.to_string()
}

#[test]
fn an_unknown_language_is_brought_down_to_the_default_rather_than_refused() {
let read = read_bundle(&bundle_with("language", "rust".into())).unwrap();

assert_eq!(read.bundle.notes[0].language, Language::default());
assert_eq!(read.degraded.len(), 1);
assert!(read.degraded.contains(&read.bundle.notes[0].id));
}

#[test]
fn an_unknown_kind_is_brought_down_the_same_way() {
let read = read_bundle(&bundle_with("kind", "table".into())).unwrap();

assert_eq!(read.bundle.notes[0].kind, NoteKind::default());
assert_eq!(read.degraded.len(), 1);
}

#[test]
fn a_known_value_is_left_exactly_as_written() {
let read = read_bundle(&bundle_with("language", "sql".into())).unwrap();

assert_eq!(read.bundle.notes[0].language.to_string(), "sql");
assert!(read.degraded.is_empty());
}

/// A malformed file is malformed, not a file from a newer version: only a string
/// this build cannot name is degraded.
#[test]
fn a_language_that_is_not_a_string_is_still_a_format_error() {
assert!(matches!(
read_bundle(&bundle_with("language", 42.into())).unwrap_err(),
StorageError::ImportFormat(_)
));
}

#[test]
Expand All @@ -229,8 +362,9 @@ mod tests {

let read = read_bundle(&serde_json::to_string(&bundle).unwrap()).unwrap();

assert_eq!(read.notes.len(), 1);
assert_eq!(read.notes[0].title, "Title");
assert_eq!(read.spaces[0].name, "Personal");
assert_eq!(read.bundle.notes.len(), 1);
assert_eq!(read.bundle.notes[0].title, "Title");
assert_eq!(read.bundle.spaces[0].name, "Personal");
assert!(read.degraded.is_empty());
}
}
Loading
Loading