Skip to content

Absorb sync-conflict copies of the review log - #20

Merged
Aduneer merged 1 commit into
mainfrom
sync-conflict-absorber
Aug 19, 2026
Merged

Absorb sync-conflict copies of the review log#20
Aduneer merged 1 commit into
mainfrom
sync-conflict-absorber

Conversation

@Aduneer

@Aduneer Aduneer commented Aug 19, 2026

Copy link
Copy Markdown
Owner

merge_events() and replay() have been written, tested and unused since #8. This is the command that calls them, and it is deliberately the smallest one that could: no --sync, no git, no network.

When two machines both append to deck.txt.log before they sync, the sync client cannot merge the two versions, so it keeps one and parks the other beside it as deck.txt.sync-conflict-20260818-101112-K3JQ7ZP.log. Neither copy is wrong — the log is append-only, so they are two halves of one history.

$ FlashTerm deck.txt --absorb-conflicts
Reading 1 sync-conflict copy of deck.txt.log:
  deck.txt.sync-conflict-20260818-101112-K3JQ7ZP.log: 4 events
Absorbed 3 new events into deck.txt.log, now 4 events.
  el perro                          +1 correct, box 2 -> 3, due 2026-08-25
  la casa                           +1 incorrect, due 2026-08-19
Updated 2 cards in deck.txt.
1 card named by those events is not in this deck.
Its events stay in the log, and will count once the deck catches up.
The copy was left in place. Delete when you are happy with the result:
  rm deck.txt.sync-conflict-20260818-101112-K3JQ7ZP.log

How replayed state meets counters that predate the log

This is the question that kept replay() unwired, and it is answered by never trusting replay absolutely. The log is replayed twice, before the merge and after it, and only the difference is applied. Counters gain exactly what the other machine recorded; a card whose history began before the log keeps all of it.

Whether the merged log gets to set a card's schedule turns on whether this machine's log already covered that card. If it did, replay is the whole story for it and following it backwards is right too — which is what makes an absorbed undo roll a card back rather than being silently ignored. If it did not, the deck's own state wins when it is newer, because an older event is not evidence about today's schedule however new it is to this machine.

Three deliberate choices

  • The conflict copies are read and left alone. Absorbing is idempotent — a second run finds nothing new — so leaving them costs only disk, while deleting the wrong file costs a history that exists nowhere else. The output ends with the rm line and lets the user run it.
  • Which names count is one predicate, not a list of vendors. Syncthing, Dropbox and Nextcloud each insert their marker in a different place, so both "before the extension" and "appended" are accepted, and the inserted text has to contain the word conflict. That keeps deck.txt.log.bak out, and the .tmp of an interrupted rewrite — absorbing that one would be circular.
  • Events naming cards the deck does not have are kept and reported, not dropped. The deck and the log are two files and nothing makes a sync client deliver them in step.

Also in here

EventLog gained rewrite(), the temp-file-and-rename twin of Deck::save(). An append is atomic in practice; replacing a whole log is not, and the log is the only copy of what happened.

The golden harness gained a files/ fixture directory, copied in by content: a log and the conflict copies beside it have names the sync client invents, which the fixed audio/ and voices/ names cannot express. .gitignore needed a matching exception, since *.log would otherwise have dropped the new fixtures on the floor and failed CI while passing locally — checked from a fresh clone, not assumed.

Testing

Four new golden cases and 76 new unit checks: the naming predicate including every negative, discovery and its ordering, the atomic rewrite and its failure path, the differential replay with a pre-log card that must not move, an absorbed undo, idempotence, and the two usage errors.

make check is 618 unit checks and 42 golden cases, green on gcc and clang with -Werror, clean under ASan/UBSan, and clean in an env -i clean room.

merge_events() and replay() have been written, tested and unused since #8.
This is the command that calls them, and it is deliberately the smallest one
that could: no --sync, no git, no network.

When two machines both append to deck.txt.log before they sync, the sync client
cannot merge the two versions, so it keeps one and parks the other beside it as
deck.txt.sync-conflict-20260818-101112-K3JQ7ZP.log. Neither copy is wrong --
the log is append-only, so they are two halves of one history. `FlashTerm
deck.txt --absorb-conflicts` unions them by event id, rewrites the log in
timestamp order, and brings the deck's counters and due dates up to date with
the reviews it just gained.

The question that kept replay() unwired -- how replayed state should meet
counters that predate the log -- is answered by never trusting replay
absolutely. The log is replayed twice, before the merge and after it, and only
the difference is applied. Counters gain exactly what the other machine
recorded; a card whose history began before the log keeps all of it. Whether
the merged log gets to set a card's schedule turns on whether this machine's
log already covered that card: if it did, replay is the whole story for it and
following it backwards is right too, which is what makes an absorbed *undo*
roll a card back rather than being silently ignored.

Three deliberate choices worth stating:

* The conflict copies are read and left alone. Absorbing is idempotent -- a
  second run finds nothing new -- so leaving them costs only disk, while
  deleting the wrong file costs a history that exists nowhere else. The output
  ends with the `rm` line, and lets the user run it.
* Which names count is one predicate, not a list of vendors. Syncthing, Dropbox
  and Nextcloud each insert their marker in a different place, so both "before
  the extension" and "appended" are accepted, and the inserted text has to
  contain the word "conflict". That keeps deck.txt.log.bak out, and the .tmp of
  an interrupted rewrite -- absorbing that one would be circular.
* Events naming cards the deck does not have are kept and reported rather than
  dropped. The deck and the log are two files and nothing makes a sync client
  deliver them in step.

EventLog gained rewrite(), the temp-file-and-rename twin of Deck::save(). An
append is atomic in practice; replacing a whole log is not, and the log is the
only copy of what happened.

The golden harness gained a files/ fixture directory, copied in by content:
a log and the conflict copies beside it have names the sync client invents,
which the fixed audio/ and voices/ names cannot express. .gitignore needed a
matching exception, since *.log would otherwise have dropped the new fixtures
on the floor and failed CI while passing here.

Four new golden cases and 76 new unit checks: the naming predicate including
every negative, discovery and its ordering, the atomic rewrite and its failure
path, the differential replay with a pre-log card that must not move, an
absorbed undo, idempotence, and the two usage errors.

make check is 618 unit checks and 42 golden cases, green on gcc and clang with
-Werror, clean under ASan/UBSan, and clean in an `env -i` clean room.
@Aduneer
Aduneer merged commit 2d41441 into main Aug 19, 2026
2 checks passed
@Aduneer
Aduneer deleted the sync-conflict-absorber branch August 19, 2026 17:06
Aduneer added a commit that referenced this pull request Aug 19, 2026
Pictures on cards, sync that puts two machines' reviews back together, and a CI
matrix that checks what used to be checked by hand. PRs #20-#23.

The CHANGELOG entries gained their PR numbers, which the file's own preamble
promises and these four had been written without.

No transcript changed for the version bump, which is what the <VERSION>
placeholder in normalise.awk exists for: releasing does not touch 47 expected
files. The demo GIF is already current -- it was re-rendered in #22, and doing
so is what caught the two terminal-detection bugs that release fixes.
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