Elyra
Elyra The coding agent eTerm The terminal that knows where each command ends Starf An activity monitor for Apple silicon that never invents a number 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 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.

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 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.
  • 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.