fix(composer): durable publish state for thread idempotency (closes #17) #34

Merged
starsetbyte merged 2 commits from fix/composer-idempotent-publish into main 2026-06-21 20:33:11 +00:00
Owner

Summary

Issue #17 identified two failure modes in thread publication:

  1. Ambiguous timeout → duplicate threads. Every publish() call generated fresh rkeys, so if segment N's createRecord timed out but actually succeeded server-side, retrying re-posted it with a new rkey — creating a duplicate thread root. The pre-generated rkeys weren't persisted or reused.
  2. Partial failure → lost segments. On PartialPublishException, the ViewModel deleted the draft and closed the composer, so unposted segments were gone for good.

Fix — durable publish outbox keyed to the draft

  • drafts.publishStateJson (new column, Room v8→v9 additive MIGRATION_8_9): a JSON array of SegmentPublishState(rkey, published, uri, cid), one per segment.
  • publish() now takes a draftId, generates rkeys once, persists them before the loop, and updates each segment's state as it succeeds. On retry (same draftId with persisted state) it:
    • reuses the same rkeys — the AT Protocol idempotency key. Re-POSTing the same rkey upserts the record, so an ambiguous timeout can't create a duplicate.
    • skips already-published segments — resume continues from the first unpublished one.
    • reconstructs the thread root/parent chain from the published segments' stored uri/cid.
  • ViewModel: post() flushes the draft (so the row exists) then publishes. On partial failure it keeps the draft and surfaces partialProgress ("Published N of M — Retry will resume — no duplicate posts") with Retry / Discard remaining buttons instead of closing. Non-partial failures clear the publish state so the next manual post starts clean.
  • Process-death recovery: init detects a persisted partial-publish state on composer open and shows the resume UI immediately.

Test plan

  • ComposerPublishStateTest (6 new tests):
    • rkeys persisted before the loop
    • retry reuses the same rkey after partial failure
    • resume reconstructs thread parent from last published segment
    • stale publish state discarded when segment count changes
    • clearPublishState nulls the column
    • hasPartialPublish logic (mixed/all-published/no-state)
  • Full suite green: 221 tests, 0 failures (was 215)
  • assembleDebug builds, Room schema v9 regenerates
  • Device verification: trigger a partial publish (kill network mid-thread) and confirm Retry resumes without duplicates

💘 Generated with Crush

## Summary Issue #17 identified two failure modes in thread publication: 1. **Ambiguous timeout → duplicate threads.** Every `publish()` call generated fresh rkeys, so if segment N's `createRecord` timed out but actually succeeded server-side, retrying re-posted it with a new rkey — creating a duplicate thread root. The pre-generated rkeys weren't persisted or reused. 2. **Partial failure → lost segments.** On `PartialPublishException`, the ViewModel deleted the draft and closed the composer, so unposted segments were gone for good. ### Fix — durable publish outbox keyed to the draft - **`drafts.publishStateJson`** (new column, Room v8→v9 additive `MIGRATION_8_9`): a JSON array of `SegmentPublishState(rkey, published, uri, cid)`, one per segment. - **`publish()`** now takes a `draftId`, generates rkeys once, persists them **before** the loop, and updates each segment's state as it succeeds. On retry (same draftId with persisted state) it: - **reuses the same rkeys** — the AT Protocol idempotency key. Re-POSTing the same rkey upserts the record, so an ambiguous timeout can't create a duplicate. - **skips already-published segments** — resume continues from the first unpublished one. - **reconstructs the thread root/parent chain** from the published segments' stored uri/cid. - **ViewModel**: `post()` flushes the draft (so the row exists) then publishes. On partial failure it keeps the draft and surfaces `partialProgress` ("Published N of M — Retry will resume — no duplicate posts") with **Retry** / **Discard remaining** buttons instead of closing. Non-partial failures clear the publish state so the next manual post starts clean. - **Process-death recovery**: `init` detects a persisted partial-publish state on composer open and shows the resume UI immediately. ## Test plan - [x] `ComposerPublishStateTest` (6 new tests): - rkeys persisted before the loop - retry reuses the same rkey after partial failure - resume reconstructs thread parent from last published segment - stale publish state discarded when segment count changes - `clearPublishState` nulls the column - `hasPartialPublish` logic (mixed/all-published/no-state) - [x] Full suite green: **221 tests, 0 failures** (was 215) - [x] `assembleDebug` builds, Room schema v9 regenerates - [ ] Device verification: trigger a partial publish (kill network mid-thread) and confirm Retry resumes without duplicates 💘 Generated with Crush
fix(composer): durable publish state for thread idempotency (closes #17)
All checks were successful
peregrine-ci / assembleDebug Build succeeded (30s)
ce9de5f37c
Thread publication reused fresh rkeys on every publish() call, so an
ambiguous timeout on segment N that actually succeeded server-side would
create a duplicate thread on retry. On partial failure the draft was
deleted and the composer closed, losing unposted segments.

Add a durable publish outbox keyed to the draft: rkeys are generated once,
persisted to a new drafts.publishStateJson column (Room v8→v9 additive
migration) before the loop, and reused on retry. Each segment records its
published uri/cid as it succeeds. A resume skips already-published segments
and reconstructs the thread root/parent chain from the persisted state, so
re-POSTing an in-flight rkey upserts instead of duplicating.

The composer now surfaces "Published N of M — Retry" on partial failure
instead of closing, and resume also works across process death (the
ViewModel detects a persisted partial state on open). Non-partial failures
clear the publish state so the next manual post starts clean.

💘 Generated with Crush
Merge branch 'main' into fix/composer-idempotent-publish
All checks were successful
peregrine-ci / assembleDebug Build succeeded (12s)
3133fe40eb
Sign in to join this conversation.
No description provided.