1/** 2 * LWW conflict resolution for the non-CRDT (REST) fields — TASK-0095 R1. 3 * 4 * The doc §5.5 "last-writer-wins" split (the FINAL rule, recorded against the 5 * doc's plain LWW): 6 * - the PAGE TITLE (a CRDT-free field) = **LWW-with-consent** (strictly 7 * safer than the doc's plain LWW): when a title PATCH is replayed after a 8 * remote title change (the REST CAS 409 — the remote write won the field), 9 * the client does NOT silently overwrite. It refetches, shows a toast 10 * ("Title was changed by Ana — your change 'X' was not applied") + an 11 * "Apply anyway" action. Applying sends a fresh PATCH — an explicit user 12 * choice. (The doc says "LWW + a toast"; the consent variant still toasts 13 * and is strictly safer — recorded.) 14 * - a DATABASE PROPERTY VALUE (a record cell) = **plain LWW**: the newer 15 * timestamp wins, the toast says "Updated to the latest value" (no consent 16 * — a record cell is low-stakes and the doc's LWW is the right default). 17 * 18 * The two-field split is the load-bearing decision: the same 409 means 19 * "ask the user" for a title but "apply silently (newer wins)" for a property. 20 * This module is PURE (no UI, no fetch, no store) — the outbox worker + the 21 * web UI call it. AGENTS rule 23 (domain logic independent from the UI). 22 */ 23 24/** A field value + its write timestamp (+ who wrote it, for the toast). */ 25export interface FieldVersion { 26 readonly value: string; 27 /** The write's client/server timestamp (ms). */ 28 readonly ts: number; 29 /** The remote author's display name (the toast's "changed by Ana"). */ 30 readonly author?: string; 31} 32 33export type ConflictField = "title" | "property"; 34 35/** The outcome of a 409 on a non-CRDT field (the outbox worker/UI act on it). */ 36export type LwwOutcome = 37 /** Title: do NOT auto-apply. The UI refetches + toasts + offers "Apply anyway". */ 38 | { 39 readonly kind: "consent"; 40 /** The toast text ("Title was changed by Ana — your change 'X' was not applied"). */ 41 readonly toast: string; 42 /** The apply action (a fresh PATCH — the user's explicit choice). */ 43 readonly applyAction: "fresh-patch"; 44 /** The local value the user's change carried (the apply re-sends it). */ 45 readonly localValue: string; 46 } 47 /** Property (LWW, the local write is newer or tied): keep the local value. */ 48 | { 49 readonly kind: "lww-local-wins"; 50 readonly value: string; 51 } 52 /** Property (LWW, the remote write is newer): take the remote value + toast. */ 53 | { 54 readonly kind: "lww-remote-wins"; 55 readonly value: string; 56 readonly toast: string; 57 }; 58 59/** 60 * Compare two write timestamps (the plain-LWW core). The LATER write wins; a 61 * TIE resolves to the LOCAL write (the local edit is the user's in-front-of- 62 * them intent — the conservative default). Returns the winning side. 63 */ 64export function compareLwwTimestamps(localTs: number, remoteTs: number): "local" | "remote" { 65 return localTs >= remoteTs ? "local" : "remote"; 66} 67 68/** 69 * Resolve a 409 on a non-CRDT field (the two-field split). `local` is the 70 * user's in-flight write (the one the outbox is replaying); `remote` is the 71 * current server state (the 409's fresh value — the one that won the CAS). 72 */ 73export function resolveFieldConflict( 74 field: ConflictField, 75 local: FieldVersion, 76 remote: FieldVersion, 77): LwwOutcome { 78 if (field === "title") { 79 // Title = LWW-with-consent (NEVER auto-overwrite). The 409 means the 80 // remote write won the field; the local ts does not auto-apply — the user 81 // decides via the "Apply anyway" action. 82 return { 83 kind: "consent", 84 toast: `Title was changed by ${remote.author ?? "someone"} — your change '${local.value}' was not applied`, 85 applyAction: "fresh-patch", 86 localValue: local.value, 87 }; 88 } 89 // Property = plain LWW: the newer timestamp wins. 90 if (compareLwwTimestamps(local.ts, remote.ts) === "local") { 91 return { kind: "lww-local-wins", value: local.value }; 92 } 93 return { kind: "lww-remote-wins", value: remote.value, toast: "Updated to the latest value" }; 94} 95