Skip to content

Remove a block only as it was read, and name the copy when copy-block fails - #126

Merged
muellerei merged 3 commits into
mainfrom
fix/copy-block-remove-reread
Oct 13, 2026
Merged

muellerei merged 3 commits into
mainfrom
fix/copy-block-remove-reread

Conversation

@muellerei

Copy link
Copy Markdown
Owner

Closes #125.

Summary

Every removal of a block a command read now checks that the block is still what was read. copy-block --remove checks against the state it copied.

 LogseqAPI.remove_block(uuid, *, expect_tree, written_here=False)
+  if expect_tree is not None
+    current = getBlock(uuid, includeChildren, past the cache)
+    none           -> BlockNotFound, nothing removed
+    tree_hash != expect_tree -> BlockChanged, nothing removed
   _write(removeBlock)   # editor gate, proof

expect_tree has no default, so every caller says what it read:

copy-block --remove      tree_hash(source as copied)
remove-block             tree_hash(block as read and counted)
remove-property          tree_hash(property block), when its last key goes
strictinsert stand-in    None (written a request earlier by the same call)
 copy_block
   read source
   copy root
+  on_failure("The copy is <uuid>.", copied_id=<uuid>)
   copy children
-  remove_block(source)
+  try remove_block(source, expect_tree=tree_hash(source))
+  except BlockNotFound: source_gone = True   # exit 0, the move is done

output.on_failure is read by both failure paths (a refused or unproven write, and a connection failure), so the command needs no try per kind of failure. A refusal from the removal no longer says "repeat": a retry makes a second copy.

Evidence

  • Before: a source with three children, a fourth added after the second copied block: exit 0, "blocks": 4, the source gone, the copy without the fourth child. A source deleted during the copy: exit 0, "action": "moved", no hint. remove-block on a block deleted between its read and its delete: exit 0, reported removed.
    After: test_a_child_added_during_the_copy_is_not_removed: exit 1, block_changed, copied_id = the copy's root, the fourth child still under the source. test_a_source_deleted_during_the_copy_is_a_move_done: exit 0, source_gone: true. test_remove_block_keeps_a_child_made_between_read_and_delete and test_remove_property_keeps_a_property_block_that_got_a_child: block_changed, nothing removed.
  • Each new test was red first. Mutations, each run against its own test file: dropping the re-read in remove_block, comparing with a fresh read instead of the copied state, not registering copied_id, and not catching BlockNotFound in copy-block each make their tests fail.
  • Full suite: 4046 passed, 8 skipped. ruff check clean.

Merge Danger

Door: two-way

No data or format changes. Reverting restores the old removal.

Blast Radius: removals

remove-block changes behaviour: it refuses with block_changed when anything under the block changed since its read, and reports a block gone by then as block_not_found (exit 1) where it used to report it removed. Every removal costs one more getBlock. The window between that read and removeBlock stays (the editor check, with a block open also its read of the subtree), since the API has no compare-and-swap; docs/safety.md says so. A root placed before the empty blocks at the end of a page (keep_empty_blocks_last) whose place could not be proven is not named in copied_id.

removeBlock cascades, so a child added between a command's read and its
delete went with the block. LogseqAPI.remove_block now takes a required
expect_tree: the tree hash of the block as the caller read it, or None for
a block nobody read a subtree of (strictinsert's stand-in). With a hash it
reads the block with its children again past the cache, right before the
delete, and raises BlockChanged when the tree is another and BlockNotFound
when the block is gone. No default, so a new caller cannot skip the check
by forgetting it.

remove-block and the page's property block in remove-property pass the
hash of their read; copy-block passes None until it gets its own check.
copy-block writes the copy block by block and removes the source last, with
no rollback. A failure after the copy's root landed left a copy in the
target that a caller could not find (the error object had only
writes_landed), and a retry made a second one.

The command now registers copied_id and "The copy is <uuid>." with
output.on_failure as soon as the root is proven; the two ways a failure is
reported (a refused or unproven write, and a connection failure) add them
to the error object and the message. A root that did not land names
nothing.
copy-block --remove reads the source once, copies it block by block and then
removes it, and the removal cascades: a child added under the source while
the copy ran was deleted, missing from the copy, and the command reported
success. Reproduced against the test double.

The removal now passes the tree hash of the state that was copied, so the
API refuses with block_changed when anything under the source changed,
removing nothing; the copy is named in copied_id. A source deleted
elsewhere during the copy is a move done: exit 0 with source_gone, since a
failure would invite cleaning up the only version left.

docs/safety.md replaces the known gap with what stays open: the requests
between the last read and the delete, a copy that breaks off, and a ref set
during the copy.
@muellerei
muellerei force-pushed the fix/copy-block-remove-reread branch from dae1f96 to ce29d89 Compare October 13, 2026 13:35
@muellerei
muellerei merged commit 4dd62b4 into main Oct 13, 2026
6 checks passed
@muellerei
muellerei deleted the fix/copy-block-remove-reread branch October 13, 2026 13:52
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.

copy-block --remove deletes only what it copied

1 participant