Storage
Notr keeps the workspace in one SQLite database, by default
~/Library/Application Support/Notr/notr.db (NOTR_DB overrides it). The database is
opened in WAL mode with synchronous = NORMAL and foreign keys on. All access is from
the main thread.
Schema
PRAGMA user_version records the schema version. MIGRATIONS in
notr-store/src/lib.rs has one step per version, each run in its own transaction; a
database from a newer Notr is refused rather than opened.
meta key → value: settings (JSON), revision, schema, generation
notebooks id, position, title, color
section_groups id, notebook_id, parent_id, position, title, color, collapsed
sections id, notebook_id, group_id, position, title, color
pages id, section_id, position, title, created, modified, level,
favorite, collapsed, paper, paper_color, deleted_from, deleted_at
blocks id, page_id → pages, position, kind, x, y, width, height, text,
format, marks, checked, tags, cells, file_name, media_type
attachments block_id → blocks, data
strokes id, page_id → pages, position, tool, color, width, points
versions id, page_id → pages, position, created, description, json
- Order is the
positioncolumn within its parent. - A page in the recycle bin has no
section_id;deleted_fromanddeleted_atremember where it was and when it was deleted. format,marks,tagsandcellsare JSON, in the same shape as in.notespacefiles. Mark offsets are UTF-16 code units.pointsis a blob of little-endianf32triples: x, y, pressure.- Colors are ARGB integers. Dates are ISO 8601 with an offset.
blocks,attachments,strokesandversionsare deleted with their page (ON DELETE CASCADE).
Loading reads every table and assembles the Workspace, then validates it. An invalid
database is reported and left untouched.
Incremental saving
Store::save(workspace, dirty) writes what dirty names in one IMMEDIATE
transaction:
- Settings and the revision are always upserted into
meta. - A page edit (
dirty.pages) upserts that page's row and, if its content changed, its blocks, strokes and versions. - A workspace change (
dirty.all) rewrites the small organization tables (notebooks, groups, sections), upserts every page row, writes the content of pages that changed, and deletes pages that are gone.
To keep that cheap:
- The store remembers a content fingerprint for every page it has read or written: a hash of its blocks (without the attachment bytes) and the IDs of its strokes and versions. A page whose fingerprint is unchanged doesn't have its content written.
- Upserts have a
WHERE (…) IS NOT (excluded.…)clause, so rows whose values are the same aren't rewritten. - Attachment bytes live in their own table and are written with
INSERT OR IGNOREwhen a block first appears. Updating a block never rewrites its file. This relies on the model's rule that a block's data never changes under the same ID (copies and duplicates get new IDs). - Strokes and versions are immutable in the model, so they are inserted once;
only their
positionis updated when strokes before them are erased. - Rows that disappeared from a page are deleted by ID.
Conflicts
The generation value in meta counts saves. The store remembers the generation it
loaded or last wrote; a save first compares it with the database's, inside the write
transaction. If another process has saved in between, the save fails with
Error::Conflict and writes nothing, and the app stops saving and tells the user to
export and reopen. Two copies of Notr therefore can't silently overwrite each other.
Why not a JSON file
NoteSpace saves the whole workspace as one JSON string after every change. Rows make a save proportional to the change instead of to the workspace, keep pictures out of every rewrite, and let the database be inspected with ordinary tools:
sqlite3 ~/Library/Application\ Support/Notr/notr.db \
"select title from pages where section_id is not null order by modified desc limit 10"
.notespace remains the interchange and backup format; see
The .notespace format.