/** * LWW conflict resolution for the non-CRDT (REST) fields — TASK-0095 R1. * * The doc §5.5 "last-writer-wins" split (the FINAL rule, recorded against the * doc's plain LWW): * - the PAGE TITLE (a CRDT-free field) = **LWW-with-consent** (strictly * safer than the doc's plain LWW): when a title PATCH is replayed after a * remote title change (the REST CAS 409 — the remote write won the field), * the client does NOT silently overwrite. It refetches, shows a toast * ("Title was changed by Ana — your change 'X' was not applied") + an * "Apply anyway" action. Applying sends a fresh PATCH — an explicit user * choice. (The doc says "LWW + a toast"; the consent variant still toasts * and is strictly safer — recorded.) * - a DATABASE PROPERTY VALUE (a record cell) = **plain LWW**: the newer * timestamp wins, the toast says "Updated to the latest value" (no consent * — a record cell is low-stakes and the doc's LWW is the right default). * * The two-field split is the load-bearing decision: the same 409 means * "ask the user" for a title but "apply silently (newer wins)" for a property. * This module is PURE (no UI, no fetch, no store) — the outbox worker + the * web UI call it. AGENTS rule 23 (domain logic independent from the UI). */ /** A field value + its write timestamp (+ who wrote it, for the toast). */ export interface FieldVersion { readonly value: string; /** The write's client/server timestamp (ms). */ readonly ts: number; /** The remote author's display name (the toast's "changed by Ana"). */ readonly author?: string; } export type ConflictField = "title" | "property"; /** The outcome of a 409 on a non-CRDT field (the outbox worker/UI act on it). */ export type LwwOutcome = /** Title: do NOT auto-apply. The UI refetches + toasts + offers "Apply anyway". */ | { readonly kind: "consent"; /** The toast text ("Title was changed by Ana — your change 'X' was not applied"). */ readonly toast: string; /** The apply action (a fresh PATCH — the user's explicit choice). */ readonly applyAction: "fresh-patch"; /** The local value the user's change carried (the apply re-sends it). */ readonly localValue: string; } /** Property (LWW, the local write is newer or tied): keep the local value. */ | { readonly kind: "lww-local-wins"; readonly value: string; } /** Property (LWW, the remote write is newer): take the remote value + toast. */ | { readonly kind: "lww-remote-wins"; readonly value: string; readonly toast: string; }; /** * Compare two write timestamps (the plain-LWW core). The LATER write wins; a * TIE resolves to the LOCAL write (the local edit is the user's in-front-of- * them intent — the conservative default). Returns the winning side. */ export function compareLwwTimestamps(localTs: number, remoteTs: number): "local" | "remote" { return localTs >= remoteTs ? "local" : "remote"; } /** * Resolve a 409 on a non-CRDT field (the two-field split). `local` is the * user's in-flight write (the one the outbox is replaying); `remote` is the * current server state (the 409's fresh value — the one that won the CAS). */ export function resolveFieldConflict( field: ConflictField, local: FieldVersion, remote: FieldVersion, ): LwwOutcome { if (field === "title") { // Title = LWW-with-consent (NEVER auto-overwrite). The 409 means the // remote write won the field; the local ts does not auto-apply — the user // decides via the "Apply anyway" action. return { kind: "consent", toast: `Title was changed by ${remote.author ?? "someone"} — your change '${local.value}' was not applied`, applyAction: "fresh-patch", localValue: local.value, }; } // Property = plain LWW: the newer timestamp wins. if (compareLwwTimestamps(local.ts, remote.ts) === "local") { return { kind: "lww-local-wins", value: local.value }; } return { kind: "lww-remote-wins", value: remote.value, toast: "Updated to the latest value" }; }