Skip to content

Commit b939919

Browse files
author
Duncan
committed
merge hayt/canvas-history-desktop: Desktop canvas history UI
* hayt/canvas-history-desktop: fix(canvas): address canvas history review nits feat(desktop): canvas version history, restore, and conflict-checked save Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
2 parents 8c3645a + 9246546 commit b939919

21 files changed

Lines changed: 1374 additions & 102 deletions

desktop/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
"@tiptap/starter-kit": "^3.22.3",
6464
"class-variance-authority": "^0.7.1",
6565
"clsx": "^2.1.1",
66+
"diff": "^8.0.4",
6667
"embla-carousel-react": "^8.6.0",
6768
"emoji-mart": "^5.6.0",
6869
"jdenticon": "^3.3.0",

desktop/src-tauri/src/commands/canvas.rs

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ use tauri::State;
33
use crate::{
44
app_state::AppState,
55
events,
6+
managed_agents::persona_events::monotonic_created_at,
67
relay::{query_relay, submit_event},
78
};
89

@@ -46,15 +47,166 @@ pub async fn get_canvas(
4647
pub async fn set_canvas(
4748
channel_id: String,
4849
content: String,
50+
expected_revision: Option<String>,
4951
state: State<'_, AppState>,
5052
) -> Result<serde_json::Value, String> {
5153
let uuid = uuid::Uuid::parse_str(&channel_id)
5254
.map_err(|_| format!("invalid channel UUID: {channel_id}"))?;
53-
let builder = events::build_set_canvas(uuid, &content)?;
55+
56+
// Writer discipline (contract v3): sign `created_at = max(now, head + 1)`
57+
// so an accepted tagged write always sorts strictly ahead of the head it
58+
// asserts (`created_at DESC, id ASC`). Without this, a same-second or
59+
// behind-clock writer could satisfy the precondition yet lose the relay's
60+
// tiebreak, "succeeding" without changing the visible canvas. Only a real
61+
// head id has a timestamp to clear; `none`/absent asserts no prior head.
62+
let prior_head_created_at = match expected_revision.as_deref() {
63+
Some(rev) if rev.len() == 64 && rev.bytes().all(|b| b.is_ascii_hexdigit()) => {
64+
asserted_head_created_at(&state, &channel_id, rev).await?
65+
}
66+
_ => None,
67+
};
68+
69+
let builder = events::build_set_canvas(uuid, &content, expected_revision.as_deref())?
70+
.custom_created_at(monotonic_created_at(prior_head_created_at));
5471
let result = submit_event(builder, &state).await?;
5572

5673
Ok(serde_json::json!({
5774
"ok": true,
5875
"event_id": result.event_id,
5976
}))
6077
}
78+
79+
/// `created_at` of the asserted head, or `None` if the relay no longer holds
80+
/// that revision. An id-scoped query is immutable, so the answer cannot shift
81+
/// under a concurrent write; a missing head lets the relay surface the
82+
/// `conflict: canvas revision does not exist` reject on submit rather than
83+
/// masking it with a stale floor here.
84+
async fn asserted_head_created_at(
85+
state: &AppState,
86+
channel_id: &str,
87+
revision: &str,
88+
) -> Result<Option<i64>, String> {
89+
let events = query_relay(
90+
state,
91+
&[serde_json::json!({
92+
"kinds": [40100],
93+
"#h": [channel_id],
94+
"ids": [revision],
95+
"limit": 1
96+
})],
97+
)
98+
.await?;
99+
Ok(events
100+
.first()
101+
.map(|event| event.created_at.as_secs() as i64))
102+
}
103+
104+
/// One page of a channel canvas's revision stream (kind:40100), newest first.
105+
/// Each 40100 write is a regular signed event the relay retains, so the
106+
/// standard query surface holds the complete history. The composite
107+
/// `(until, before_id)` cursor mirrors the relay read order
108+
/// (`created_at DESC, id ASC`) so paging never skips or repeats a revision when
109+
/// several share the same second. `next_cursor` is present only when a full
110+
/// page came back, i.e. older revisions may remain.
111+
#[tauri::command]
112+
pub async fn get_canvas_history(
113+
channel_id: String,
114+
limit: Option<usize>,
115+
until: Option<u64>,
116+
before_id: Option<String>,
117+
state: State<'_, AppState>,
118+
) -> Result<serde_json::Value, String> {
119+
if before_id.is_some() && until.is_none() {
120+
return Err("before_id requires until".to_string());
121+
}
122+
// Bound the page size to the relay's read maximum. Beyond 1,000 the relay
123+
// silently clamps the returned rows, which would make `events.len() ==
124+
// page_size` false and null the cursor even when older revisions remain,
125+
// stranding them behind an unreachable page.
126+
let page_size = resolve_history_page_size(limit)?;
127+
128+
let mut filter = serde_json::json!({
129+
"kinds": [40100],
130+
"#h": [channel_id],
131+
"limit": page_size,
132+
});
133+
if let Some(value) = until {
134+
filter["until"] = serde_json::json!(value);
135+
}
136+
if let Some(ref value) = before_id {
137+
if value.len() != 64 || !value.bytes().all(|b| b.is_ascii_hexdigit()) {
138+
return Err("before_id must be a 64-character hex event id".to_string());
139+
}
140+
filter["before_id"] = serde_json::json!(value);
141+
}
142+
143+
let events = query_relay(&state, &[filter]).await?;
144+
145+
let revisions: Vec<serde_json::Value> = events
146+
.iter()
147+
.map(|event| {
148+
serde_json::json!({
149+
"event_id": event.id.to_hex(),
150+
"content": event.content,
151+
"created_at": event.created_at.as_secs(),
152+
"author": event.pubkey.to_hex(),
153+
})
154+
})
155+
.collect();
156+
157+
// A full page means the relay may hold older revisions; hand back the
158+
// last event as the cursor for the next "Load older" request. A short page
159+
// is the tail, so there is no next cursor.
160+
let next_cursor = if events.len() == page_size {
161+
events.last().map(|last| {
162+
serde_json::json!({
163+
"created_at": last.created_at.as_secs(),
164+
"event_id": last.id.to_hex(),
165+
})
166+
})
167+
} else {
168+
None
169+
};
170+
171+
Ok(serde_json::json!({
172+
"revisions": revisions,
173+
"next_cursor": next_cursor,
174+
}))
175+
}
176+
177+
/// Resolve and validate the history page size against the relay's read
178+
/// maximum. Defaults to 100 when unset; a value outside `1..=1000` is rejected
179+
/// so cursor generation is never based on a size the relay would silently
180+
/// clamp (which strands older revisions behind a falsely-terminated page).
181+
fn resolve_history_page_size(limit: Option<usize>) -> Result<usize, String> {
182+
let page_size = limit.unwrap_or(100);
183+
if !(1..=1000).contains(&page_size) {
184+
return Err("limit must be between 1 and 1000".to_string());
185+
}
186+
Ok(page_size)
187+
}
188+
189+
#[cfg(test)]
190+
mod tests {
191+
use super::resolve_history_page_size;
192+
193+
#[test]
194+
fn defaults_to_100_when_unset() {
195+
assert_eq!(resolve_history_page_size(None).unwrap(), 100);
196+
}
197+
198+
#[test]
199+
fn rejects_zero() {
200+
assert!(resolve_history_page_size(Some(0)).is_err());
201+
}
202+
203+
#[test]
204+
fn accepts_relay_maximum() {
205+
assert_eq!(resolve_history_page_size(Some(1000)).unwrap(), 1000);
206+
}
207+
208+
#[test]
209+
fn rejects_above_relay_maximum() {
210+
assert!(resolve_history_page_size(Some(1001)).is_err());
211+
}
212+
}

desktop/src-tauri/src/events.rs

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -416,9 +416,21 @@ pub fn build_remove_reaction(reaction_event_id: EventId) -> Result<EventBuilder,
416416
// ── Canvas ───────────────────────────────────────────────────────────────────
417417

418418
/// Kind 40100 — set canvas.
419-
pub fn build_set_canvas(channel_id: Uuid, content: &str) -> Result<EventBuilder, String> {
419+
///
420+
/// When `expected_revision` is `Some`, an `["expected-revision", <event-id>]`
421+
/// tag is attached so the relay can reject the write if the canvas head moved
422+
/// since the client loaded it (optimistic concurrency). Omitting it preserves
423+
/// the historical unconditional-append behavior.
424+
pub fn build_set_canvas(
425+
channel_id: Uuid,
426+
content: &str,
427+
expected_revision: Option<&str>,
428+
) -> Result<EventBuilder, String> {
420429
check_content(content)?;
421-
let tags = vec![tag(vec!["h", &channel_id.to_string()])?];
430+
let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?];
431+
if let Some(revision) = expected_revision {
432+
tags.push(tag(vec!["expected-revision", revision])?);
433+
}
422434
Ok(EventBuilder::new(Kind::Custom(40100), content).tags(tags))
423435
}
424436

desktop/src-tauri/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -635,6 +635,7 @@ pub fn run() {
635635
join_channel,
636636
leave_channel,
637637
get_canvas,
638+
get_canvas_history,
638639
set_canvas,
639640
get_feed,
640641
search_messages,
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
4+
import {
5+
CANVAS_EXPECTED_REVISION_NONE,
6+
isCanvasConflictError,
7+
} from "./canvasConflict.ts";
8+
9+
// The three frozen relay reject strings are all conflicts from the user's
10+
// perspective: the head moved, the revision the client expected no longer
11+
// exists, or the write does not sort strictly ahead of the current head
12+
// (contract v3). The helper must recognize each whether it arrives as an Error
13+
// or a raw string (the Tauri IPC layer hands back either), and must not misfire
14+
// on unrelated errors.
15+
16+
test("head-moved reject is a conflict as Error and as raw string", () => {
17+
const message = "conflict: canvas changed since it was loaded";
18+
assert.equal(isCanvasConflictError(new Error(message)), true);
19+
assert.equal(isCanvasConflictError(message), true);
20+
});
21+
22+
test("revision-does-not-exist reject is a conflict as Error and as raw string", () => {
23+
const message = "conflict: canvas revision does not exist";
24+
assert.equal(isCanvasConflictError(new Error(message)), true);
25+
assert.equal(isCanvasConflictError(message), true);
26+
});
27+
28+
test("does-not-supersede reject is a conflict as Error and as raw string", () => {
29+
const message = "conflict: canvas write does not supersede the current head";
30+
assert.equal(isCanvasConflictError(new Error(message)), true);
31+
assert.equal(isCanvasConflictError(message), true);
32+
});
33+
34+
test("conflict marker embedded in a longer wrapped message still matches", () => {
35+
const wrapped = new Error(
36+
"submit failed: conflict: canvas revision does not exist (relay)",
37+
);
38+
assert.equal(isCanvasConflictError(wrapped), true);
39+
});
40+
41+
test("unrelated errors are not conflicts", () => {
42+
assert.equal(isCanvasConflictError(new Error("relay unreachable")), false);
43+
assert.equal(isCanvasConflictError("some other failure"), false);
44+
assert.equal(isCanvasConflictError(null), false);
45+
assert.equal(isCanvasConflictError(undefined), false);
46+
assert.equal(
47+
isCanvasConflictError({
48+
message: "conflict: canvas changed since it was loaded",
49+
}),
50+
false,
51+
);
52+
});
53+
54+
test("the create-race sentinel is the literal contract value", () => {
55+
assert.equal(CANVAS_EXPECTED_REVISION_NONE, "none");
56+
});
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
/**
2+
* Optimistic-concurrency conflict detection for the channel canvas.
3+
*
4+
* A conflict-checked save (`set_canvas` / restore) sends an
5+
* `["expected-revision", <head event id | "none">]` tag. The relay rejects the
6+
* write when the live head no longer matches what the client loaded, and the
7+
* Rust submit path surfaces that as an error whose message contains one of the
8+
* frozen relay strings below. Callers use this to render a distinct "canvas
9+
* changed — reload" state instead of a generic error.
10+
*
11+
* Two reject strings are both conflicts from the user's perspective:
12+
* - the head moved since load, and
13+
* - the revision the client expected no longer exists (e.g. it expected a head
14+
* but the canvas was never created, or was replaced out from under it).
15+
* A third arises under contract v3's head-advancement guarantee: a write whose
16+
* precondition matches but which does not sort strictly ahead of the asserted
17+
* head (`created_at DESC, id ASC`) is rejected so an accepted tagged write is
18+
* always the new visible head.
19+
*
20+
* Contract: the relay reject strings are frozen (`crates/**`, Duncan's PR1). Do
21+
* not change these substrings without updating the relay in lockstep.
22+
*/
23+
const CANVAS_CONFLICT_MARKERS = [
24+
"conflict: canvas changed since it was loaded",
25+
"conflict: canvas revision does not exist",
26+
"conflict: canvas write does not supersede the current head",
27+
] as const;
28+
29+
export const CANVAS_CONFLICT_MESSAGE =
30+
"This canvas changed since you loaded it — reload to see the latest, then reapply your edit.";
31+
32+
/**
33+
* Literal `expected-revision` value asserting "I expect no canvas exists yet".
34+
* Sent by the first save of a new canvas so a concurrent first creation is
35+
* rejected as a conflict rather than silently overwritten. Frozen contract
36+
* value (`crates/**`, Duncan's PR1).
37+
*/
38+
export const CANVAS_EXPECTED_REVISION_NONE = "none";
39+
40+
/**
41+
* True when `error` is the relay's optimistic-concurrency conflict — the head
42+
* moved or the expected revision no longer exists between the load and the
43+
* save. Accepts `Error` instances and raw strings so callers can pass whatever
44+
* the Tauri IPC layer hands them.
45+
*/
46+
export function isCanvasConflictError(error: unknown): boolean {
47+
const message =
48+
error instanceof Error
49+
? error.message
50+
: typeof error === "string"
51+
? error
52+
: null;
53+
if (message === null) {
54+
return false;
55+
}
56+
return CANVAS_CONFLICT_MARKERS.some((marker) => message.includes(marker));
57+
}

0 commit comments

Comments
 (0)