Split a series by creating the new part first - #97
Merged
Merged
Conversation
"This and all following" truncated the series, then created the tail, and put the head back if the create failed. Putting it back restored only the rule: the changed and deleted occurrences the truncate had dropped at the provider (Google deletes them, CalDAV no longer writes them) stayed dropped. `writeSeriesSplit` now creates the tail first and truncates after (decision 136). If the truncate fails, what happens depends on whether it certainly changed nothing (decision 144): - Refused (a refusal token, or `conflict`, `forbidden`, `invalid_input`, `not_found`, `auth`, `unsupported`): the tail is deleted again with the create's notify flag, an exact undo, and the refusal is reported. If that delete fails too, the failure is marked (`seriesMaybeShownTwice`). - Anything else (network, protocol, no code) may have reached the provider, and deleting the tail then would leave the series ending at the cutoff. Both stay, and the split counts as written (decision 145): it returns `headCut: 'unsure'` with the failure, instead of throwing a failure a retry would repeat by writing the tail twice. `writeNeverLanded` in eventWriteError.ts says which failures are refusals; on the phone only `forbidden`, `conflict` and `network` carry their code (B9), so every other refusal counts as unsure there. The four callers follow. An unsure cut is a save: the editors give the tail its private reminders and colour and announce that the series may show twice from the day of the cut, with what went wrong. The carry dialogs count the copy, group its new part, and stay open with a note per such copy, without "try again". A tail that could not be deleted again is said the same way: the editors ask to check before saving again, the carries do not offer the copy again. `cutoffDay` writes the day in the reader's language. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- CalDAV: `send_retrying` replays after a connection that died once the
request was out, and the server may have applied the first attempt. The
replay of a guarded PUT then got 412, read as a conflict, so a split
deleted its new series around a truncate that had landed.
`send_retrying_marked` says whether it replayed: a 412 on a replayed
guarded PUT is now a network error ("it may have been saved"), and a 404
on a replayed DELETE counts as deleted. The http.rs doc no longer claims
such a request was never processed.
- Creating first dropped decision 106's proof: the create invalidated the
calendar, so the truncate right after kept no fields, and on Exchange it
wrote this device's copy of the master over another device's change.
`CacheStore::invalidate_after_create` keeps the proof (a create changes
no cached row) unless another write began or ended meanwhile; both hosts
use it for creates.
- Decision 146: after an unsure cut the editor stays open in place of the
form, with the warning focused on screen and only Close, which then goes
on to the carry offer or closes. An announcement alone was never seen by
sighted users, and the carry dialog opening next spoke over it.
- Phone carry: the outcome line is no longer an assertive live region (on
Android it cut off the announcement with the doubts); when the outcome
'written' removes the button that held the cursor, the outcome line takes
focus and the doubts are said after it, queued.
- `eventWriteFailureReason` gives a failure's reason without "Nothing was
changed", for the sentence that says a split's new series could not be
taken back.
- Comments, the tutorial (en, de) and TODO.md follow; decision 147 (certain
refusals marked by the adapters, Graph's DELETE after /cancel) is noted
for the next PR.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- CalDAV: a replayed 404 counted as deleted, but a bare id names a different URL in every calendar, so the home-set walk stopped in a calendar that never held the event, and the one that did got no DELETE: a split's new series was reported deleted while it stood. A replayed 404 is now `GoneOnReplay`; `DeleteWalk`, used by the event and task walkers, goes on past it and counts it as done only when no calendar holds the event and none failed. - A connect failure sent nothing, so its replay's answer is its own: `first_attempt_may_have_landed` is false for it, and a real 412 after a failed connect stays a conflict. - Phone editor: while the split notice is up, the header button is Close, the swipe is off, and `beforeRemove` sends every way out through the one handler that goes on to the carry offer, as the desktop's Escape does. - Both editors: a notice for an editor the user already left is announced instead, and nothing navigates from it. Closing the notice goes on once and keeps it on screen until the editor goes away, so the form never comes back with a live Save in between. - Phone carry: every outcome puts the cursor on the outcome line, whose label reads the outcome, the last failure and this pass's doubts in one utterance; the error line is no longer a live region, which cut the outcome off on Android, and a queued announcement queued on iOS only. - Words: a test doc spliced onto the wrong test; http.rs's "never processed" and what replaying is safe for, naming MKCALENDAR's 405 on replay (older, now in TODO.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second PR of the arc "exceptions when splitting a series" (decision 134), decisions 136, 144 and 145.
Why
"This and all following" truncated the series, then created the new part (the tail), and put the head back if the create failed. Putting it back restored only the rule. The truncate had already dropped the changed and deleted occurrences after the cut at the provider: Google deletes those instances, and CalDAV writes the series without them. They stayed dropped, with nothing said. The host's calendar move solved the same problem the other way round: create first, so a half-failed move never loses data.
The change
Create first, then truncate (136).
writeSeriesSplitnow creates the tail, then truncates the head. If the truncate fails, the question is whether it certainly changed nothing (144):conflict,forbidden,invalid_input,not_found,auth,unsupported. The tail is deleted again with the create's notify flag: an exact undo, and the refusal is reported as before.writeNeverLandedinshared/eventWriteError.tsdecides this.An unsure cut is a save with a warning (145). The tail exists, so throwing would invite a retry, and a retry writes the tail a second time.
writeSeriesSplitreturns{ tail, headCut: 'unsure', failure }instead:If the undo fails too (refused, then the delete of the tail fails), it stays a failure, marked
seriesMaybeShownTwice:The day of the cut is written by
cutoffDayin the reader's language, on the day the occurrence is shown.Callers. Both editors and both carry dialogs, desktop and phone in parity.
seriesLeftTruncatedand its sentence are gone. Four new sentences, in EN and DE.Docs. The tutorial (en and de) says what happens when ending the old series fails. TODO.md records the change.
Checks
tsc,eslintclean.TZ=UTC.writeSeriesSplit:headCut: 'unsure'with the failure;writeNeverLanded: every refusing code on both surfaces; tokens whatever the code; network, protocol, internal and codeless errors never count.cutoffDay: local day at half past midnight, in German and English; unreadable input kept.Review round
One adversarial round (3 lenses: provider errors, logic and parity, screen reader; every finding verified). 21 confirmed, several of them the same finding from two lenses; 3 refuted. Fixed here:
send_retryingreplays after a connection that died once the request was out, and the server may already have applied the first attempt. The replayed guarded PUT then gets 412, which read asconflict, a certain refusal, so the new series was deleted around a truncate that had landed.send_retrying_markednow says whether it replayed. A 412 on a replayed guarded PUT is a network error ("it may have been saved"), and a 404 on a replayed DELETE counts as deleted: the undo of a split no longer reports a tail that is gone as still there. The http.rs doc said the server "demonstrably never processed" such a request; corrected.CacheStore::invalidate_after_createkeeps the proof, because a create changes no row the cache holds. It keeps it only if no other write began or ended meanwhile. Both hosts use it for creates.eventWriteFailureReasongives the refusal's reason alone, with new reason sentences in EN and DE.seriesLeftTruncated.sendCancellations: true);eventWriteFailureReason;Deferred (decision 147, the next PR): certain refusals that arrive as
protocolmake both series stay with the warning although nothing was cut. That covers EWS fault answers, Google and Graph HTTP 400 and 429, and CalDAV's own "nothing was saved" checks. The adapters will mark themserver-refused. The same PR takes Graph's DELETE after a successful/cancel, which may answer 404 and so reports the undo as failed. Both are in TODO.md.Not covered by a test: the one-line use of
invalidate_after_createon each host. Neither host's create command has a harness with an external calendar.Checks for the round:
-D warningsclean;adapter-caldavandhost-corealone clean.tscandeslintclean; vitest 2189 passed, locally and underTZ=UTC.Second review (of the review round)
Two lenses (Rust; UI): 10 confirmed, 4 refuted. All fixed here:
GoneOnReplay. The walk goes on past it and counts it as done only when no calendar holds the event and none failed.DeleteWalkholds this rule for both the event walker and the task walker.beforeRemovesends every way out through the same handler.Checks for the second round:
Rust: 2850 passed; fmt and clippy
-D warningsclean.TypeScript: desktop and mobile
tscandeslintclean; vitest 2190 passed, locally and underTZ=UTC.New tests: the walk goes on past a replayed 404; a replayed 404 found nowhere else is gone, but a failure elsewhere wins; only a request that went out may have landed; the Escape test above.
Red proofs, 6 of 6 red, tree restored byte for byte:
(A first run of the previous round's proofs hung on a break of mine that locked the same mutex twice. The tree was restored from the backup and checked; that proof was rewritten and is red.)
Worth knowing
invalid_input,not_found,authandunsupportedarrive without their code (TODO B9). A truncate refused for one of those reasons counts as unsure there: both stay, with the warning, rather than the new series being deleted.🤖 Generated with Claude Code