feat(api): conditional item updates with quoted etag preflight #47
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/issue-11-updates"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Motivation
Callers could already create items, but an existing item could not be changed safely: the only way to update one is to rewrite a whole CalDAV object, which either overwrites a concurrent external edit or round-trips an external resource through the new-item builder and drops alarms, unknown properties, parameters, timezone definitions and unrelated overrides. This PR closes that gap (R03/C10/C09/R01): an update carries the caller's own quoted ETag, so a stale baseline is an explicit conflict rather than a silent overwrite, and only the properties the caller named are rewritten.
Changes
src/domain/patch.rs(new):TaskPatch/EventPatchmodels plus the field vocabulary now shared with the create slice (strength/energy/transparency parsing, category merging, required text, strict temporal ordering). Identity members (id,uid,collection_id,kind,recurrence_id) are refused rather than ignored; an empty patch — including one carrying only areason— changes nothing and is refused.src/calendar/preservation.rs(new): preservation-oriented patcher for an existing raw resource. It unfolds the resource, rewrites only the requested properties of the component selected byUID(andRECURRENCE-IDwhen present), re-emits every untouched physical line verbatim, folds new values at 75 octets, and refuses withcalendar_unsupportedwhen the target cannot be identified unambiguously (unknown UID, duplicate identity, malformed resource). An existing object is never serialized through the new-item builder.src/api/update.rs(new):PATCH /v1/tasks/{id}andPATCH /v1/events/{id}. Order of work: operation key +If-Matchpreflight (no substitute baseline is ever fetched) → live item resolution → patch validation against the resource's actual state (ordering is checked on the state the resource is left in) → prepared intent carrying target, baseline ETag, before-state and intended representation → conditionalPUTunder the caller's exact ETag → one transaction recordingapplied, appendingtask_updated/event_updatedwithbefore_json/after_json, and upserting the projection. A412recordsfailedand returnsetag_conflictat HTTP 412 / exit 4 with no retry and no fresh-ETag refetch. A recorded operation is answered from the journal alone, so a replay never re-dispatches (and works even if the resource is gone). Unconfirmed outcomes stayneeds_reconciliation+partial(exit 6).src/calendar/client.rs:put_with_precondition(conditionalPUTunderIf-Match) andreread_etagadded to the existing pooled transport; the written representation, not a later readback, is the after-state.src/api/mod.rs,src/api/items.rs: mutation plumbing extracted from the create slice and shared (mutation-result payload, replay/recorded-failure/operation-error envelopes, operation-key and body parsing, the actor identity,422mapping for patch errors,etag_precondition_failedat 412 next to create's 409); live item resolution extracted so update and item-get use one implementation, and absence is no longer claimed when a collection could not be read.src/cli.rs,src/client.rs:life task update ID --etag ETAG --input - [--operation-id KEY] [--json]andlife event update .... The CLI refuses a missing, empty, weak or unquoted ETag locally (exit 2, no request), forwards the quoted ETag verbatim asIf-Match, and on a conflict points the caller atlife item get <ID>.src/cli.rsauthor metadata now readsYuris Cakranegara <hello@yuris.dev>(clap derives from it; no behaviour change — clap's default help template does not print the author).Deliberately out of scope: lifecycle transitions complete/reopen/cancel (#12) and recurrence/occurrence scoping (#13–#15). An occurrence-scoped patch is refused with
calendar_unsupportedrather than guessed.Verification
nix develop --command cargo test— all suites green: 164 tests, including the newtests/update_tests.rs(27 HTTP-level tests against a mock CalDAV) and 8 patcher unit tests.nix develop --command cargo clippy --all-targets -- -D warnings— clean.nix develop --command cargo fmt --check— clean.*/multi-tag ETags are rejected with 400 + exit 2 with zero requests reaching the backend and no journal row; the quoted ETag is captured verbatim asIf-Match; ordering violations (including one-sided patches that invert the bounds) and identity members return 422 with noPUT; patching a title/deadline preservesVALARM,X-properties, custom parameters,DESCRIPTION,VTIMEZONEand a same-UID detached override in the exact bytes sent; a stale baseline yields 412 +etag_conflict,operations.state='failed', no retry and no fetch after the rejected write; a successful update commitsappliedplus atask_updatedfact whose before/after snapshots are the pre- and post- item states; replaying an operation returns the recorded result with onePUTin total (and still resolves after the resource disappears); a byte-identical projection of what was written is stored; the CLI round-trips exit codes 0/2/4 and its envelope.Related Issues
Closes #11
Coordinator review — APPROVED (with one recorded deviation)
Verified
fb95de7by reading the diff and running it myself, not by trusting the summary:required_if_matchis evaluated beforeresolve_live_itemand beforeprepare_operation/put_with_precondition, so a missing/empty/weak/unquoted/*/multi-tagIf-Matchcannot reach the backend.validate_etagreturns the caller's value verbatim after structural checks, so the quoted ETag is forwarded exactly.operations.state='failed', no retry, no post-rejection refetch.src/calendar/preservation.rsunfolds logical lines, walks BEGIN/END nesting, rewrites only the direct properties of the component selected by UID (and RECURRENCE-ID), and re-emits every untouched physical line verbatim; new values are folded at 75 octets and re-terminated with CRLF, so output stays RFC 5545 conformant. NestedVALARMand sub-components are never descended into, and an ambiguous or unidentifiable target is refused withcalendar_unsupportedinstead of guessed.run_updaterefuses an item whoserecurrenceis set (calendar_unsupported) rather than applying a master patch — that would have been data corruption.escape_textescapes\ ; ,and drops CR, so a title cannot introduce a new content line.Independent verification on
fb95de7:cargo test164 passed / 0 failed across 12 suites includingupdate_tests(27);cargo clippy --all-targets -- -D warningsclean;cargo fmt --checkclean.Cargo.tomlis untouched by this branch (author line correctly reverted);src/cli.rscarries the corrected author alongside the reallife task update/life event updatework.Recorded deviation (no code change required)
docs/technical/calendar.mdnames theicalcrate for component parsing; this slice instead implements a lossless line/component AST insrc/calendar/preservation.rswith no new dependency. That is explicitly permitted by #14's own specification ("leveragingical::parser::ical::IcalParseror a dedicated lossless line/component AST"), and for a preservation-critical path it is the stronger guarantee — a parser round-trip is precisely the failure mode C09 exists to prevent. Recording it here for traceability so #14 does not read the absence oficalas an oversight.Merging.
feat(api): conditional item updates with quoted etag preflight (#11)to feat(api): conditional item updates with quoted etag preflight