fix(03): WR-06 surface move-failed distinctly with re-save guidance
This commit is contained in:
@@ -434,10 +434,23 @@ export async function runOutboxDrain(): Promise<void> {
|
|||||||
const result = await dispatchRow(row)
|
const result = await dispatchRow(row)
|
||||||
|
|
||||||
if (result.conflict) {
|
if (result.conflict) {
|
||||||
|
// WR-06: distinguish an edit-as-move create-412 from a same-calendar conflict.
|
||||||
|
// For a move (D-04) the create runs first; on 412 the paired delete is later
|
||||||
|
// marked failed and the original event survives — so this is NOT a "the event
|
||||||
|
// changed elsewhere" conflict, it is "the move could not be applied". The PWA
|
||||||
|
// set lastSyncedUid to the NEW (move) uid, whose only outbox row is this failed
|
||||||
|
// create, so without a distinct message the user sees the wrong conflict copy
|
||||||
|
// and has no cue to retry. Emit a move-specific lastError that does NOT contain
|
||||||
|
// '412' so the toast routes it to the dedicated move-failed copy instead of the
|
||||||
|
// generic etag-conflict copy.
|
||||||
|
const isMoveCreate = !!row.groupId && row.operation === 'create'
|
||||||
|
const conflictError = isMoveCreate
|
||||||
|
? 'move-failed: the event could not be moved — re-open it and save again'
|
||||||
|
: (result.error ?? '412 conflict')
|
||||||
// 412 — mark failed (no retry), re-sync calendar so UI sees authoritative state (D-08)
|
// 412 — mark failed (no retry), re-sync calendar so UI sees authoritative state (D-08)
|
||||||
await db
|
await db
|
||||||
.update(calendarOutbox)
|
.update(calendarOutbox)
|
||||||
.set({ status: 'failed', lastError: result.error ?? '412 conflict' })
|
.set({ status: 'failed', lastError: conflictError })
|
||||||
.where(eq(calendarOutbox.id, row.id))
|
.where(eq(calendarOutbox.id, row.id))
|
||||||
await triggerTargetedResync(row.calendarUrl, row.userId)
|
await triggerTargetedResync(row.calendarUrl, row.userId)
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,12 @@ export function SyncStateToast() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const status = data?.status
|
const status = data?.status
|
||||||
const isConflict = status === 'failed' && data?.error?.includes('412')
|
// WR-06: an edit-as-move whose create hits 412 dead-ends with no retry path (the
|
||||||
|
// original event is preserved, but the new uid's only outbox row is failed). The
|
||||||
|
// worker tags that case with a 'move-failed:' lastError so we can show distinct copy
|
||||||
|
// guiding the user to re-open and re-save, rather than the etag-conflict copy.
|
||||||
|
const isMoveFailed = status === 'failed' && !!data?.error?.startsWith('move-failed')
|
||||||
|
const isConflict = status === 'failed' && !isMoveFailed && data?.error?.includes('412')
|
||||||
const isPersistent = status === 'failed' || status === 'dead'
|
const isPersistent = status === 'failed' || status === 'dead'
|
||||||
|
|
||||||
// Invalidate events on done OR on conflict (D-06/D-08)
|
// Invalidate events on done OR on conflict (D-06/D-08)
|
||||||
@@ -113,6 +118,16 @@ export function SyncStateToast() {
|
|||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
} else if (isMoveFailed) {
|
||||||
|
// WR-06: the move could not be applied; the original event is unchanged.
|
||||||
|
copy = "Couldn't move the event. Open it and save again."
|
||||||
|
icon = (
|
||||||
|
<AlertCircle
|
||||||
|
size={14}
|
||||||
|
style={{ color: 'var(--color-destructive)', flexShrink: 0 }}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)
|
||||||
} else if (status === 'failed') {
|
} else if (status === 'failed') {
|
||||||
copy = "Didn't save. Try again."
|
copy = "Didn't save. Try again."
|
||||||
icon = (
|
icon = (
|
||||||
|
|||||||
Reference in New Issue
Block a user