Elyra
Elyra The coding agent eTerm The terminal that knows where each command ends Huskarl AI colleagues on your own Mac Starf An activity monitor for Apple silicon that never invents a number etrans An SSH and SFTP client for macOS Elyra VM Virtual machines for macOS, Linux and Windows on your Mac Litr A small, native web browser for macOS Notr A notebook for macOS e The native code editor Elyra Grove Native local development environment Askr The real server for Laravel & PHP Elyra Framework Rust + Svelte 5 framework for desktop apps Elyra Conductor Local project conductor Refr Local-first PDF workspace for macOS Elyra Workspace A desktop workspace for coding agents Elyra SQL Server MySQL-compatible SQL server in Rust Elyra Félagi Agents as teammates on one board Elyra SQL Client Native desktop SQL workbench Elyra SQL Anywhere Replication-ready SQL engine Elyra Sjá SEO & GEO workspace for macOS Elyra DataGrid Server-driven data grid for Laravel
Elyra

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 position column within its parent.
  • A page in the recycle bin has no section_id; deleted_from and deleted_at remember where it was and when it was deleted.
  • format, marks, tags and cells are JSON, in the same shape as in .notespace files. Mark offsets are UTF-16 code units.
  • points is a blob of little-endian f32 triples: x, y, pressure.
  • Colors are ARGB integers. Dates are ISO 8601 with an offset.
  • blocks, attachments, strokes and versions are 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 IGNORE when 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 position is 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.