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. Version 2 added the
connector columns to strokes; a database that has been opened by a Notr with
connectors can no longer be opened by 1.0.x. Version 3 added recognized, and
version 4 snapshots and snapshot_files; version 5 rebuilt snapshots without the
cascade from pages.
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,
from_block, to_block, arrow
versions id, page_id → pages, position, created, description, json
recognized key, digest, text
snapshots id, page_id, created, json, files
snapshot_files digest, data
- 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. The exception is a connector, whose points move with its notes: it is upserted, and its points are part of the page's fingerprint. - Rows that disappeared from a page are deleted by ID, and a block without data loses its attachment row.
Those rules hold within Notr, but not for a backup made elsewhere. When the workspace
is replaced (importing a .notespace file, or undoing or redoing that), the session
reports Change::replaced and the app sets Dirty::replaced. The next save then deletes
every stroke, version and attachment row and writes every page's content again.
A database without the settings row in meta counts as empty only if it also has no
notebooks and no pages; otherwise it loads with default settings.
Recognized text
recognized holds the text read in pictures and handwriting (see
notr_core::recognition and the app's ocr.rs), keyed picture:<block id> or
ink:<page id>, with an FNV-1a digest of what it was read from: a picture's bytes, or
a page's strokes (connectors and highlighter strokes left out). Something is read
again only when its digest changes. The table is derived data: it is written as soon
as a reading finishes, without the generation check, isn't part of saves or undo, and
isn't exported. Keys for pictures and pages that are gone are dropped at start.
Snapshots
snapshots holds the timeline's automatic snapshots of pages: the page's JSON without
versions or attachment bytes, and files, a JSON map from block ID to the FNV-1a
digest of its bytes in snapshot_files, where each picture is kept once.
Store::add_snapshot first thins the page's snapshots out (notr_core::versions::keep:
all from the last day, the newest of each day for 30 days), then skips a snapshot
identical to the last one left. Thinning first means a page unchanged for over 30 days
gets a fresh snapshot instead of none. Like recognized text, snapshots are written
straight away, without the generation check, and aren't part of saves, undo or
exports. They don't cascade with their page's row, so a page an import or a permanent
delete removed comes back with its history when that is undone.
Store::tidy_snapshots runs at start and every six hours while Notr is open, unless the
store couldn't be loaded. It thins every page's snapshots as they age, so pages that
stop changing thin out too, and drops files no snapshot holds. At start only, given the
workspace just loaded, it also forgets the snapshots of pages that are gone: in neither
that workspace (the recycle bin counts) nor the stored pages, with the newest a day
old (FORGET_AFTER, so another Notr that removed the page can still undo it with its
history). Later the window's own pages may be out of date (another Notr's new pages,
pages an undoable import took away), so the periodic tidy forgets nothing.
Finding those means reading every snapshot's files, so add_snapshot leaves it to
the tidy. Both take the write lock first (BEGIN IMMEDIATE), so they wait for another
Notr instead of failing.
The timeline lists a page's snapshots with snapshot_moments (ids and times only) and
loads the one being shown with snapshot, sharing picture bytes already read by
digest, so scrubbing holds one page and each picture once. The store keeps the digests
of attachment bytes it has hashed, by the bytes' address (holding the bytes while
cached), so a snapshot hashes only pictures it hasn't seen; and the app skips a page
whose modified time hasn't changed since it was last snapshotted or found unchanged.
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. Two copies of Notr therefore can't silently
overwrite each other.
The app then merges instead of stopping. It keeps a base, the workspace as it was
last loaded or saved (attachments shared, and after a page-only save just those pages
replaced). On a conflict it loads what the other copy saved (which resets the
generation and the page fingerprints to the database's), merges with
notr_core::merge::three_way(base, ours, theirs), takes the result (Session::merge_in),
saves it in full, and tells the user what came from where. The merge can't be undone,
and it clears the undo history: every earlier step holds the workspace without the
other copy's changes, so undoing one would save over them. Before the final
validation the merge repairs what both sides together can break: a section group in
two notebooks, groups inside each other (the group whose parent closes the loop is
lifted out) or nested too deep, and a recycle bin over its limit (the pages deleted
longest ago are dropped, the rest keep their order, and the notice says how many). The rules:
- a page's content and its place (section or recycle bin) merge separately; a change on one side wins over none;
- a section's order and subpage levels are those of the side that rearranged the
pages both kept there (theirs if both did); a page only the other side has there
goes after the page it followed on that side, at the same depth relative to it
(
merge::arrange); - a page whose content both sides changed differently is kept twice: theirs in place, ours after it and its subpages, at its level, as "Title (from this window)", with new identities; its subpage level, folding and favorite never make a second version (folding and favorite merge one by one, the level goes with the order);
- a page deleted on one side and edited on the other is kept, edited;
- the organisation starts from theirs, with our additions and our renames where theirs didn't change the same thing; what we deleted goes if it is empty; our pages whose section they deleted go to a "Recovered" section;
- the view settings are ours.
While a dialog is open the merge waits, since it would close the dialog and lose what was typed in it; saving resumes when the dialog closes (closing the window or quitting merges at once). The note being edited stays open if the merge leaves it as it was, and the view stays where it was.
If the merge fails (the database can't be read, or the result isn't valid), saving stops as before and the user is told to export and reopen.
This is for two copies of Notr on one Mac. Keeping the database itself in a synced
folder (iCloud Drive, Dropbox, Syncthing) is still unsafe: those copy SQLite files
while they are written, which can damage them. Move notebooks between Macs with
.notespace backups.
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.