Notes: rename, links, backlinks and the bulk-edit job #76

Closed
kayg wants to merge 0 commits from issue/9-rename-links-3 into dev
Owner

Closes #9.

What it does

  • One bulk-edit job (crates/notes/src/bulk/), used by every multi-note change (ADR 0015).
    • The plan is stored in history before anything runs.
    • Steps run in batches of 64, at most 32 at once, through each note's own room. Each step is planned against a base version and merged in, so typing is never overwritten.
    • Progress is saved after each batch. Every step carries a job-derived key, so a crash mid-batch re-runs it and adds nothing twice.
    • Undo is one more job: the inverses, newest first, each merged on the version right after its step.
    • One job runs at a time per person. Typing never waits on the job lock.
  • Rename (rename.rs, room/rename.rs).
    • The file follows the H1, about 2 s after a save with a new title (Options::rename_after, CALTERNAL_NOTES_RENAME_MS). It also happens on request with a new title (which sets the H1 in the same job).
    • Daily, weekly, monthly and yearly notes keep their date names.
    • Collisions get " 2"; case-only renames go through a temp name.
    • Every link to the note is repointed with minimal edits through the live rooms. Link text that was the old title follows; text someone chose stays.
    • Emits notes.renamed, so search refreshes the title live.
  • Links and backlinks (links.rs, backlinks.rs).
    • A parser for [t](<path.md#^block>) and old [t](path%20x.md) links. It skips code, images and web links.
    • A live in-memory link map per Home. It's built after the startup scan and kept current from note events, using the live text of open notes.
    • Unlinked mentions come from search, through a new core trait MentionFinder. Search implements it with exact phrase matches only, no typo corrections.
  • Make note / move to note (makenote.rs, blocks.rs).
    • One job across both notes; ⌘Z on the job undoes both (guard 9).
    • Headings take their whole section, and list items their children.
    • Titles come from the first sentence, cut near 80 characters. The block keeps its indent, list marker and task box.
    • Undo deletes the made note only if nobody typed in it since.

API (for the #43 UI)

  • notes.rename {id, title?} → Job
  • notes.make-note {id, line, to_line?, title?} → Job + {note, path, title}
    • line is 0-based in the note's full text; lines inside the properties are refused.
  • notes.move-block {id, line, to_line?, target} → Job
  • notes.undo {job} → Job (the undo job); notes.job {job} → Job
  • notes.backlinks {id} → {linked: [{id, title, line, snippet, link}], unlinked: [{item, title, line, snippet, link}], incomplete}
  • Job = {job, kind, state: done|failed|undone|running, done, steps, notes, summary, error}
  • With an idempotency key, the job id derives from it, so a retry (even after a crash) finds the same job.

Tests

  • Workspace: 254 pass; fmt and clippy -D warnings are clean.
  • New tests:
    • link parsing and rewriting (case and Unicode forms, subfolders, anchors)
    • inverse edits (a property test)
    • rename with full undo
    • a taken name gets a number; daily notes refuse
    • auto-rename after typing a new H1, with the link following
    • typing in a linking note during a rename is kept exactly once
    • a crash mid-job (after a batch applied, before its progress saved) resumes on restart, and every link is rewritten exactly once
    • make note and move with undo
    • undo keeps a made note someone typed in
    • backlinks with linked/unlinked filtering, and kept live from app edits, outside writes and deletes

Perf (cargo run --release -p calternal-notes --example rename_bench -- 10000 1000)

Measured on calternal-dev at load average 5 to 15 (several agents and CI running). The perf VM was busy with #44's 100k run the whole time, so these numbers are indicative, not the gate:

result budget
rename, 10k notes / 1k linking (H1 + file + 1k link edits) 1.2–2.2 s < 2 s
save-ack round trip of an edit in another note, idle p50 7–8 ms, p95 11–12 ms
…during the rename p50 8–11 ms, p95 15–35 ms < 16 ms
backlinks, random notes p95 < 1 ms < 20 ms
backlinks, hub with 1k links 1.2–2.2 ms < 20 ms

The "typing" number here is the durable save ack (history fsync), not keystroke-to-paint, which is local in the editor. During a big job it sits behind the job's grouped history writes. #32 (fan out before fsync) is the real fix for what collaborators see. The perf VM should confirm whether the 16 ms ack budget holds without load.

Not done / notes

  • Links typed but not yet saved (under 750 ms) in a note that isn't open in a room can be missed by a rename's plan.
  • Folder-level renames, tag renames and migrations can now use bulk::run; none use it yet.

Also fixes a timing flake CI hit in crates/calternal/tests/notes_live.rs: with saves 20 ms after a change, the server could save before the websocket join, so the test now types at the end of whatever text it synced.

The web job's other failure was a WebKit flake in the #42 toast primitive (toast.spec.ts:115, events during the opening), which this PR doesn't touch.

🤖 Generated with Claude Code

https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T

Closes #9. ## What it does - **One bulk-edit job** (`crates/notes/src/bulk/`), used by every multi-note change (ADR 0015). - The plan is stored in history before anything runs. - Steps run in batches of 64, at most 32 at once, through each note's own room. Each step is planned against a base version and merged in, so typing is never overwritten. - Progress is saved after each batch. Every step carries a job-derived key, so a crash mid-batch re-runs it and adds nothing twice. - Undo is one more job: the inverses, newest first, each merged on the version right after its step. - One job runs at a time per person. Typing never waits on the job lock. - **Rename** (`rename.rs`, `room/rename.rs`). - The file follows the H1, about 2 s after a save with a new title (`Options::rename_after`, `CALTERNAL_NOTES_RENAME_MS`). It also happens on request with a new title (which sets the H1 in the same job). - Daily, weekly, monthly and yearly notes keep their date names. - Collisions get " 2"; case-only renames go through a temp name. - Every link to the note is repointed with minimal edits through the live rooms. Link text that was the old title follows; text someone chose stays. - Emits `notes.renamed`, so search refreshes the title live. - **Links and backlinks** (`links.rs`, `backlinks.rs`). - A parser for `[t](<path.md#^block>)` and old `[t](path%20x.md)` links. It skips code, images and web links. - A live in-memory link map per Home. It's built after the startup scan and kept current from note events, using the live text of open notes. - Unlinked mentions come from search, through a new core trait `MentionFinder`. Search implements it with exact phrase matches only, no typo corrections. - **Make note / move to note** (`makenote.rs`, `blocks.rs`). - One job across both notes; ⌘Z on the job undoes both (guard 9). - Headings take their whole section, and list items their children. - Titles come from the first sentence, cut near 80 characters. The block keeps its indent, list marker and task box. - Undo deletes the made note only if nobody typed in it since. ## API (for the #43 UI) - `notes.rename {id, title?}` → Job - `notes.make-note {id, line, to_line?, title?}` → Job + `{note, path, title}` - `line` is 0-based in the note's full text; lines inside the properties are refused. - `notes.move-block {id, line, to_line?, target}` → Job - `notes.undo {job}` → Job (the undo job); `notes.job {job}` → Job - `notes.backlinks {id}` → `{linked: [{id, title, line, snippet, link}], unlinked: [{item, title, line, snippet, link}], incomplete}` - Job = `{job, kind, state: done|failed|undone|running, done, steps, notes, summary, error}` - With an idempotency key, the job id derives from it, so a retry (even after a crash) finds the same job. ## Tests - Workspace: 254 pass; fmt and clippy `-D warnings` are clean. - New tests: - link parsing and rewriting (case and Unicode forms, subfolders, anchors) - inverse edits (a property test) - rename with full undo - a taken name gets a number; daily notes refuse - auto-rename after typing a new H1, with the link following - typing in a linking note during a rename is kept exactly once - **a crash mid-job (after a batch applied, before its progress saved) resumes on restart, and every link is rewritten exactly once** - make note and move with undo - undo keeps a made note someone typed in - backlinks with linked/unlinked filtering, and kept live from app edits, outside writes and deletes ## Perf (`cargo run --release -p calternal-notes --example rename_bench -- 10000 1000`) Measured on calternal-dev at load average 5 to 15 (several agents and CI running). The perf VM was busy with #44's 100k run the whole time, so these numbers are indicative, not the gate: | | result | budget | |---|---|---| | rename, 10k notes / 1k linking (H1 + file + 1k link edits) | 1.2–2.2 s | < 2 s | | save-ack round trip of an edit in another note, idle | p50 7–8 ms, p95 11–12 ms | | | …during the rename | p50 8–11 ms, p95 15–35 ms | < 16 ms | | backlinks, random notes | p95 < 1 ms | < 20 ms | | backlinks, hub with 1k links | 1.2–2.2 ms | < 20 ms | The "typing" number here is the durable save ack (history fsync), not keystroke-to-paint, which is local in the editor. During a big job it sits behind the job's grouped history writes. #32 (fan out before fsync) is the real fix for what collaborators see. The perf VM should confirm whether the 16 ms ack budget holds without load. ## Not done / notes - Links typed but not yet saved (under 750 ms) in a note that isn't open in a room can be missed by a rename's plan. - Folder-level renames, tag renames and migrations can now use `bulk::run`; none use it yet. Also fixes a timing flake CI hit in `crates/calternal/tests/notes_live.rs`: with saves 20 ms after a change, the server could save before the websocket join, so the test now types at the end of whatever text it synced. The web job's other failure was a WebKit flake in the #42 toast primitive (`toast.spec.ts:115`, events during the opening), which this PR doesn't touch. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Notes: rename, links, backlinks and the bulk-edit job (#9)
Some checks failed
CI / rust (pull_request) Failing after 40s
CI / web (pull_request) Failing after 53s
ce84d27da5
- bulk: one journaled job for changes to many notes. The plan is stored
  in history first, steps run in batches through each note's room
  (merged on their planned version), progress is saved per batch, every
  step carries a job-derived key so a crash mid-batch adds nothing
  twice, and undo is one more job running the inverses newest first.
  One job per person at a time; typing is never locked out.
- rename: the file follows the H1 about 2 s after a save with a new
  title (never for daily notes), or on request with a new title.
  Collisions get " 2", case-only renames go through a temp name, every
  link to the note is repointed with minimal edits, and link text that
  was the old title follows. Emits notes.renamed (search refreshes).
- links/backlinks: a parser for standard and old-style links (skips
  code, images, the web), a live in-memory link map per Home, and
  notes.backlinks with linked and unlinked mentions. Unlinked mentions
  come from search through a new core MentionFinder trait.
- make note / move to note: notes.make-note and notes.move-block, each
  one job across both notes; undo keeps a made note someone typed in.
- actions: notes.rename, notes.make-note, notes.move-block, notes.undo,
  notes.job, notes.backlinks.
- tests: link parsing and rewriting, inverse edits (property test),
  rename with undo, auto-rename after typing, typing kept during a
  rename, a crash mid-job resuming exactly once, make note and move
  with undo, backlinks live from edits, outside writes and deletes.
- examples/rename_bench.rs: rename at 10k notes / 1k links, typing
  during it, backlinks queries.

Closes #9

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Test: a websocket join may come after the first save
Some checks failed
CI / web (pull_request) Failing after 55s
CI / rust (pull_request) Failing after 1m6s
287fc5f4cd
The live-note test saves 20 ms after a change, so on a busy CI host the
server had already saved (adding its properties) before the join, and
the test's fixed position 7 and exact text failed. It now checks the
text ends with the note's body and types at its end.

Part of #9

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Test: compare a made note's body, not its saved properties
Some checks failed
CI / rust (pull_request) Failing after 1m9s
CI / web (pull_request) Failing after 2m4s
971b8ac94a
On a busy host the new note can be saved (properties added on top)
before the test reads it.

Part of #9

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yyy8fQGxPLkLa8v8NXz7T
Author
Owner

Moved to #77 (the forge doesn't pick up new commits on this PR).

Moved to https://git.kayg.org/kayg/calternal/pulls/77 (the forge doesn't pick up new commits on this PR).
kayg closed this pull request 2026-10-13 21:14:00 +00:00
Some checks failed
CI / rust (pull_request) Failing after 1m9s
CI / web (pull_request) Failing after 2m4s

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
kayg/calternal!76
No description provided.