Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions desktop/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"@tiptap/starter-kit": "^3.22.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"diff": "^8.0.4",
"embla-carousel-react": "^8.6.0",
"emoji-mart": "^5.6.0",
"jdenticon": "^3.3.0",
Expand Down
154 changes: 153 additions & 1 deletion desktop/src-tauri/src/commands/canvas.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ use tauri::State;
use crate::{
app_state::AppState,
events,
managed_agents::persona_events::monotonic_created_at,
relay::{query_relay, submit_event},
};

Expand Down Expand Up @@ -46,15 +47,166 @@ pub async fn get_canvas(
pub async fn set_canvas(
channel_id: String,
content: String,
expected_revision: Option<String>,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
let uuid = uuid::Uuid::parse_str(&channel_id)
.map_err(|_| format!("invalid channel UUID: {channel_id}"))?;
let builder = events::build_set_canvas(uuid, &content)?;

// Writer discipline (contract v3): sign `created_at = max(now, head + 1)`
// so an accepted tagged write always sorts strictly ahead of the head it
// asserts (`created_at DESC, id ASC`). Without this, a same-second or
// behind-clock writer could satisfy the precondition yet lose the relay's
// tiebreak, "succeeding" without changing the visible canvas. Only a real
// head id has a timestamp to clear; `none`/absent asserts no prior head.
let prior_head_created_at = match expected_revision.as_deref() {
Some(rev) if rev.len() == 64 && rev.bytes().all(|b| b.is_ascii_hexdigit()) => {
asserted_head_created_at(&state, &channel_id, rev).await?
}
_ => None,
};

let builder = events::build_set_canvas(uuid, &content, expected_revision.as_deref())?
.custom_created_at(monotonic_created_at(prior_head_created_at));
let result = submit_event(builder, &state).await?;

Ok(serde_json::json!({
"ok": true,
"event_id": result.event_id,
}))
}

/// `created_at` of the asserted head, or `None` if the relay no longer holds
/// that revision. An id-scoped query is immutable, so the answer cannot shift
/// under a concurrent write; a missing head lets the relay surface the
/// `conflict: canvas revision does not exist` reject on submit rather than
/// masking it with a stale floor here.
async fn asserted_head_created_at(
state: &AppState,
channel_id: &str,
revision: &str,
) -> Result<Option<i64>, String> {
let events = query_relay(
state,
&[serde_json::json!({
"kinds": [40100],
"#h": [channel_id],
"ids": [revision],
"limit": 1
})],
)
.await?;
Ok(events
.first()
.map(|event| event.created_at.as_secs() as i64))
}

/// One page of a channel canvas's revision stream (kind:40100), newest first.
/// Each 40100 write is a regular signed event the relay retains, so the
/// standard query surface holds the complete history. The composite
/// `(until, before_id)` cursor mirrors the relay read order
/// (`created_at DESC, id ASC`) so paging never skips or repeats a revision when
/// several share the same second. `next_cursor` is present only when a full
/// page came back, i.e. older revisions may remain.
#[tauri::command]
pub async fn get_canvas_history(
channel_id: String,
limit: Option<usize>,
until: Option<u64>,
before_id: Option<String>,
state: State<'_, AppState>,
) -> Result<serde_json::Value, String> {
if before_id.is_some() && until.is_none() {
return Err("before_id requires until".to_string());
}
// Bound the page size to the relay's read maximum. Beyond 1,000 the relay
// silently clamps the returned rows, which would make `events.len() ==
// page_size` false and null the cursor even when older revisions remain,
// stranding them behind an unreachable page.
let page_size = resolve_history_page_size(limit)?;

let mut filter = serde_json::json!({
"kinds": [40100],
"#h": [channel_id],
"limit": page_size,
});
if let Some(value) = until {
filter["until"] = serde_json::json!(value);
}
if let Some(ref value) = before_id {
if value.len() != 64 || !value.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err("before_id must be a 64-character hex event id".to_string());
}
filter["before_id"] = serde_json::json!(value);
}

let events = query_relay(&state, &[filter]).await?;

let revisions: Vec<serde_json::Value> = events
.iter()
.map(|event| {
serde_json::json!({
"event_id": event.id.to_hex(),
"content": event.content,
"created_at": event.created_at.as_secs(),
"author": event.pubkey.to_hex(),
})
})
.collect();

// A full page means the relay may hold older revisions; hand back the
// last event as the cursor for the next "Load older" request. A short page
// is the tail, so there is no next cursor.
let next_cursor = if events.len() == page_size {
events.last().map(|last| {
serde_json::json!({
"created_at": last.created_at.as_secs(),
"event_id": last.id.to_hex(),
})
})
} else {
None
};

Ok(serde_json::json!({
"revisions": revisions,
"next_cursor": next_cursor,
}))
}

/// Resolve and validate the history page size against the relay's read
/// maximum. Defaults to 100 when unset; a value outside `1..=1000` is rejected
/// so cursor generation is never based on a size the relay would silently
/// clamp (which strands older revisions behind a falsely-terminated page).
fn resolve_history_page_size(limit: Option<usize>) -> Result<usize, String> {
let page_size = limit.unwrap_or(100);
if !(1..=1000).contains(&page_size) {
return Err("limit must be between 1 and 1000".to_string());
}
Ok(page_size)
}

#[cfg(test)]
mod tests {
use super::resolve_history_page_size;

#[test]
fn defaults_to_100_when_unset() {
assert_eq!(resolve_history_page_size(None).unwrap(), 100);
}

#[test]
fn rejects_zero() {
assert!(resolve_history_page_size(Some(0)).is_err());
}

#[test]
fn accepts_relay_maximum() {
assert_eq!(resolve_history_page_size(Some(1000)).unwrap(), 1000);
}

#[test]
fn rejects_above_relay_maximum() {
assert!(resolve_history_page_size(Some(1001)).is_err());
}
}
16 changes: 14 additions & 2 deletions desktop/src-tauri/src/events.rs
Original file line number Diff line number Diff line change
Expand Up @@ -416,9 +416,21 @@ pub fn build_remove_reaction(reaction_event_id: EventId) -> Result<EventBuilder,
// ── Canvas ───────────────────────────────────────────────────────────────────

/// Kind 40100 — set canvas.
pub fn build_set_canvas(channel_id: Uuid, content: &str) -> Result<EventBuilder, String> {
///
/// When `expected_revision` is `Some`, an `["expected-revision", <event-id>]`
/// tag is attached so the relay can reject the write if the canvas head moved
/// since the client loaded it (optimistic concurrency). Omitting it preserves
/// the historical unconditional-append behavior.
pub fn build_set_canvas(
channel_id: Uuid,
content: &str,
expected_revision: Option<&str>,
) -> Result<EventBuilder, String> {
check_content(content)?;
let tags = vec![tag(vec!["h", &channel_id.to_string()])?];
let mut tags = vec![tag(vec!["h", &channel_id.to_string()])?];
if let Some(revision) = expected_revision {
tags.push(tag(vec!["expected-revision", revision])?);
}
Ok(EventBuilder::new(Kind::Custom(40100), content).tags(tags))
}

Expand Down
1 change: 1 addition & 0 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -635,6 +635,7 @@ pub fn run() {
join_channel,
leave_channel,
get_canvas,
get_canvas_history,
set_canvas,
get_feed,
search_messages,
Expand Down
56 changes: 56 additions & 0 deletions desktop/src/features/channels/canvasConflict.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import assert from "node:assert/strict";
import test from "node:test";

import {
CANVAS_EXPECTED_REVISION_NONE,
isCanvasConflictError,
} from "./canvasConflict.ts";

// The three frozen relay reject strings are all conflicts from the user's
// perspective: the head moved, the revision the client expected no longer
// exists, or the write does not sort strictly ahead of the current head
// (contract v3). The helper must recognize each whether it arrives as an Error
// or a raw string (the Tauri IPC layer hands back either), and must not misfire
// on unrelated errors.

test("head-moved reject is a conflict as Error and as raw string", () => {
const message = "conflict: canvas changed since it was loaded";
assert.equal(isCanvasConflictError(new Error(message)), true);
assert.equal(isCanvasConflictError(message), true);
});

test("revision-does-not-exist reject is a conflict as Error and as raw string", () => {
const message = "conflict: canvas revision does not exist";
assert.equal(isCanvasConflictError(new Error(message)), true);
assert.equal(isCanvasConflictError(message), true);
});

test("does-not-supersede reject is a conflict as Error and as raw string", () => {
const message = "conflict: canvas write does not supersede the current head";
assert.equal(isCanvasConflictError(new Error(message)), true);
assert.equal(isCanvasConflictError(message), true);
});

test("conflict marker embedded in a longer wrapped message still matches", () => {
const wrapped = new Error(
"submit failed: conflict: canvas revision does not exist (relay)",
);
assert.equal(isCanvasConflictError(wrapped), true);
});

test("unrelated errors are not conflicts", () => {
assert.equal(isCanvasConflictError(new Error("relay unreachable")), false);
assert.equal(isCanvasConflictError("some other failure"), false);
assert.equal(isCanvasConflictError(null), false);
assert.equal(isCanvasConflictError(undefined), false);
assert.equal(
isCanvasConflictError({
message: "conflict: canvas changed since it was loaded",
}),
false,
);
});

test("the create-race sentinel is the literal contract value", () => {
assert.equal(CANVAS_EXPECTED_REVISION_NONE, "none");
});
57 changes: 57 additions & 0 deletions desktop/src/features/channels/canvasConflict.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* Optimistic-concurrency conflict detection for the channel canvas.
*
* A conflict-checked save (`set_canvas` / restore) sends an
* `["expected-revision", <head event id | "none">]` tag. The relay rejects the
* write when the live head no longer matches what the client loaded, and the
* Rust submit path surfaces that as an error whose message contains one of the
* frozen relay strings below. Callers use this to render a distinct "canvas
* changed — reload" state instead of a generic error.
*
* Two reject strings are both conflicts from the user's perspective:
* - the head moved since load, and
* - the revision the client expected no longer exists (e.g. it expected a head
* but the canvas was never created, or was replaced out from under it).
* A third arises under contract v3's head-advancement guarantee: a write whose
* precondition matches but which does not sort strictly ahead of the asserted
* head (`created_at DESC, id ASC`) is rejected so an accepted tagged write is
* always the new visible head.
*
* Contract: the relay reject strings are frozen (`crates/**`, Duncan's PR1). Do
* not change these substrings without updating the relay in lockstep.
*/
const CANVAS_CONFLICT_MARKERS = [
"conflict: canvas changed since it was loaded",
"conflict: canvas revision does not exist",
"conflict: canvas write does not supersede the current head",
] as const;

export const CANVAS_CONFLICT_MESSAGE =
"This canvas changed since you loaded it — reload to see the latest, then reapply your edit.";

/**
* Literal `expected-revision` value asserting "I expect no canvas exists yet".
* Sent by the first save of a new canvas so a concurrent first creation is
* rejected as a conflict rather than silently overwritten. Frozen contract
* value (`crates/**`, Duncan's PR1).
*/
export const CANVAS_EXPECTED_REVISION_NONE = "none";

/**
* True when `error` is the relay's optimistic-concurrency conflict — the head
* moved or the expected revision no longer exists between the load and the
* save. Accepts `Error` instances and raw strings so callers can pass whatever
* the Tauri IPC layer hands them.
*/
export function isCanvasConflictError(error: unknown): boolean {
const message =
error instanceof Error
? error.message
: typeof error === "string"
? error
: null;
if (message === null) {
return false;
}
return CANVAS_CONFLICT_MARKERS.some((marker) => message.includes(marker));
}
Loading
Loading