@@ -3,6 +3,7 @@ use tauri::State;
33use 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(
4647pub 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+ }
0 commit comments