Skip to content

Add the note catalog: typed rows, and queries by path and ULID (CRDT step 3) - #131

Merged
pedersen merged 2 commits into
mainfrom
worktree-crdt-catalog
Sep 5, 2026
Merged

Add the note catalog: typed rows, and queries by path and ULID (CRDT step 3)#131
pedersen merged 2 commits into
mainfrom
worktree-crdt-catalog

Conversation

@pedersen

@pedersen pedersen commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Adds the note catalog: one row per note in metadata.db, keyed by ULID,
carrying path, merge_policy, state, materialized_hash, size,
mtime_utc, sketch, and this device's view of the seed claim. The table is
bf_catalog; the queries are byPath and byUlid.

The row types are split from their storage. catalog.dart holds
MergePolicy, NoteState, and CatalogRow and imports neither dart:io nor
sqlite3crdt_lf itself is pure Dart, so a row compiles on every platform
even where the table cannot exist. catalog_io.dart is the SQLite half. The
seam exports the types unconditionally and the storage only on dart:io.

merge_policy is an open enum rather than a boolean, and both enums are
stored as their names rather than as ordinals: an ordinal re-numbers itself
the moment vector ink is added, silently reinterpreting every row already on
disk. An unrecognised extension derives blobLww, defaulting toward the
recoverable failure — last-writer-wins on text loses an edit that still exists
in the loser's history, while character-merging a PNG produces a file nobody
can recover.

Uniqueness on path is a partial index over non-tombstoned rows rather
than a column constraint. A tombstone keeps the path it died at, so deleting a
note and later creating another at that same path is ordinary use that a total
UNIQUE would reject. The partial index states what is actually true — a
live, history-pending, or unavailable note owns its path exclusively — and is
what lets byPath return one row rather than a list. byUlid still finds
tombstones, because a note's identity outlives its file.

A row that cannot be read is refused rather than half-read, matching how the
store already treats a schema version or a peer identity it cannot parse. Half
a seed claim is refused too: reading a lone seeded_by as "nobody has seeded
this" would invite a second device to seed a document that already has a
history, which is the one thing note identity must never allow.

The seed claim is an OperationId. That is exactly the (seeded_by, seed_hlc) pair the design names, and its compareTo is exactly the locked
tiebreak comparator, so contested claims resolve through the library's
ordering instead of a second one written here.

Two judgment calls worth a look

  • store_exceptions.dart. MetadataDatabaseException and
    EngramStoreCollisionException moved out of metadata_db_io.dart so
    catalog_io.dart can raise them without importing the file that imports it.
    Both platform builds now re-export one pair of classes rather than declaring
    look-alikes a catch clause would tell apart. Existing imports are
    unaffected — the io file re-exports them.
  • byPath excludes tombstones. Defensible either way; the alternative is
    returning them and making every caller filter. Excluded here because
    returning a tombstone is what would let the next scan resurrect dead history
    under unrelated content.

Scope

Step 3 as the plan specifies it: the table, a typed row, and the two queries.
The scan that populates it is step 11, the materializer that sets
materialized_hash is step 8, and the sketch is computed in step 11 — this
step only stores them.

Tests

catalog_test.dart covers the value types (policy derivation including
dotfiles, multiple extensions, and a dot in a directory name; both parsers;
row equality field by field, with byte-wise sketch comparison).
catalog_io_test.dart covers the table (every column round-tripping, UTC
normalisation of mtime, whole-row upsert semantics, the path-uniqueness
rules in all four states, and every refusal path). The plan's named test —
select path, ulid from bf_catalog answering on its own — is in the
the human-readable claim group.

New files are at 100% line coverage; the tree is at 98.50%.

Docs

Corrects the design's diagnostic example, which still named an unprefixed
catalog table from before the bf_ rule landed in step 2.

docs/manual-test-plan.md is untouched: the catalog has no UI, so no
cases were added, changed, or invalidated.

🤖 Generated with Claude Code

…step 3)

One row per note in metadata.db, keyed by ULID: path, merge policy, state,
the content hash and the size, mtime, and sketch beside it, plus this
device's view of the seed claim. The table is bf_catalog, and the queries
are byPath and byUlid.

Splits the row types from their storage. catalog.dart holds MergePolicy,
NoteState, and CatalogRow and imports neither dart:io nor sqlite3 — crdt_lf
itself is pure Dart, so a row compiles on every platform even where the
table cannot exist. catalog_io.dart is the SQLite half. The seam exports
the types unconditionally and the storage only on dart:io.

merge_policy is an open enum rather than a boolean, and both enums are
stored as their names rather than as ordinals: an ordinal re-numbers itself
the moment vector ink is added, silently reinterpreting every row already on
disk. An unrecognised extension derives blobLww, defaulting toward the
recoverable failure — last-writer-wins on text loses an edit that still
exists in the loser's history, while character-merging a PNG produces a file
nobody can recover.

Uniqueness on path is a partial index over non-tombstoned rows rather than a
column constraint. A tombstone keeps the path it died at, so deleting a note
and later creating another at that same path is ordinary use that a total
UNIQUE would reject. The partial index states what is actually true — a
live, history-pending, or unavailable note owns its path exclusively — and
is what lets byPath return one row rather than a list. byUlid still finds
tombstones, because a note's identity outlives its file.

A row that cannot be read is refused rather than half-read, matching how the
store already treats a schema version or a peer identity it cannot parse.
Half a seed claim is refused too: reading a lone seeded_by as "nobody has
seeded this" would invite a second device to seed a document that already
has a history, which is the one thing note identity must never allow.

The seed claim is an OperationId. That is exactly the (seeded_by, seed_hlc)
pair the design names, and its compareTo is exactly the locked tiebreak
comparator, so contested claims resolve through the library's ordering
instead of a second one written here.

Moves MetadataDatabaseException and EngramStoreCollisionException into
store_exceptions.dart so catalog_io.dart can raise them without importing
the file that imports it. Both platform builds now re-export one pair of
classes rather than declaring look-alikes a catch clause would tell apart;
existing imports are unaffected.

Corrects the design's diagnostic example, which still named an unprefixed
`catalog` table from before the bf_ rule.

No user-facing change: the catalog has no UI, so docs/manual-test-plan.md is
untouched — no cases added, changed, or invalidated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Coverage after merging worktree-crdt-catalog into main will be

98.39%

Coverage Report
FileStmtsBranchesFuncsLinesUncovered Lines
lib
   app.dart96.61%100%100%96.61%142–143
   startup_options.dart100%100%100%100%
   cli_output_io.dart0%100%100%0%10–11, 9
   cli_output_stub.dart0%100%100%0%5
lib/about
   about_screen.dart98.66%100%100%98.66%94–95
lib/commands
   app_commands.dart100%100%100%100%
   app_menu_bar.dart100%100%100%100%
   app_shortcuts.dart97.22%100%100%97.22%161
   text_editing_commands.dart100%100%100%100%
   pending_saves.dart100%100%100%100%
lib/engram
   engram.dart100%100%100%100%
   engram_repository.dart98.33%100%100%98.33%311, 49
   engram_scope.dart100%100%100%100%
   engram_startup_gate.dart100%100%100%100%
   repository_scope.dart100%100%100%100%
   asset_engram_store.dart98.15%100%100%98.15%92
   engram_store.dart100%100%100%100%
   built_in_engrams.dart100%100%100%100%
   desktop_folder_adoption.dart90%100%100%90%70
   engram_file_ops.dart100%100%100%100%
   metadata.dart100%100%100%100%
   id.dart100%100%100%100%
   container_resolver.dart100%100%100%100%
lib/engram/crdt
   app_data_source.dart100%100%100%100%
   app_data_resolver_io.dart100%100%100%100%
   app_data_resolver_stub.dart100%100%100%100%
   metadata_db_stub.dart100%100%100%100%
   store_exceptions.dart100%100%100%100%
   catalog.dart100%100%100%100%
   metadata_db_io.dart100%100%100%100%
   catalog_io.dart100%100%100%100%
   schema.dart100%100%100%100%
lib/engram/fs
   engram_location.dart100%100%100%100%
   fs_store_io.dart100%100%100%100%
   fs_store_stub.dart100%100%100%100%
lib/engram/ui
   engram_browser.dart98.06%100%100%98.06%195, 197, 199–200, 204–205, 399, 423, 917
   browser_preferences.dart92.31%100%100%92.31%26
   document_edit_controller.dart100%100%100%100%
   engram_switcher.dart93.81%100%100%93.81%102–105, 212, 216
   file_tree.dart100%100%100%100%
   file_tree_node.dart100%100%100%100%
   file_viewer.dart100%100%100%100%
   folder_picker.dart100%100%100%100%
   help_overlay.dart90.91%100%100%90.91%100–102, 72
   file_path_breadcrumb.dart100%100%100%100%
   markdown_editor_pane.dart100%100%100%100%
   markdown_reader.dart100%100%100%100%
   find_in_page.dart100%100%100%100%
   markdown_source_editor.dart100%100%100%100%
lib/settings
   app_settings_controller.dart100%100%100%100%
   settings_scope.dart75%100%100%75%45–46
   device_settings.dart100%100%100%100%
   settings_store.dart98.04%100%100%98.04%206
   settings_screen.dart100%100%100%100%
   engram_pane.dart100%100%100%100%
   housekeeping_pane.dart100%100%100%100%
   setting_control.dart76.47%100%100%76.47%100, 124, 86, 93
   settings_controls.dart100%100%100%100%
   settings_registry.dart85.09%100%100%85.09%215–216, 237, 246–247, 259, 31, 36–37, 41, 52–55, 64, 82–83
   settings_shell.dart99.44%100%100%99.44%244
lib/theme
   app_settings.dart100%100%100%100%
   app_theme.dart90.91%100%100%90.91%14
   design_language.dart100%100%100%100%
lib/widgets
   app_scaffold.dart100%100%100%100%
lib/window
   window_state_stub.dart66.67%100%100%66.67%13

@pedersen
pedersen merged commit c5937f4 into main Sep 5, 2026
2 checks passed
@pedersen
pedersen deleted the worktree-crdt-catalog branch September 5, 2026 15:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant