mirror of
https://github.com/block/buzz.git
synced 2026-11-07 18:01:06 +00:00
feat(canvas): add version history with atomic restore (#6780)
🤖 Adds append-only channel canvas history, restore, and relay-authoritative optimistic concurrency so stale edits cannot silently replace a newer canvas. ## What this adds - **Desktop history and restore** — browse revisions with author, timestamp, preview, and diff; restore an older revision as a new signed head after explicit confirmation. Persisted empty canvases remain reachable, mutation outcomes are announced accessibly, and focus moves to a stable destination after save or restore. - **CLI history and restore** — `buzz canvas history --channel …`, `buzz canvas get --channel … --revision <id>`, and `buzz canvas restore --channel … --revision <id>` use keyset pagination over the retained revision stream. - **SDK canvas builders** — optional `expected-revision` tags, bounded ancestry classification, and shared timestamp discipline keep first-party writes ahead of the head they read without propagating poisoned future timestamps. ## Authoritative concurrency contract Tagged canvas writes use compare-and-swap semantics in the relay and database: - `expected-revision=none` succeeds only when no live canvas exists. - An event ID succeeds only when it names the canonical live head. - A byte-identical replay succeeds only when that event is already the canonical live head. - A successful candidate must sort strictly ahead of the current head under `created_at DESC, id ASC`. - Malformed preconditions and stale or non-advancing writes are rejected before event rows, mentions, or fan-out are committed. The database serializes tagged writes, unconditional kind `40100` writes, and canvas soft deletion on the same `(community, kind, channel)` advisory key. Untagged `buzz canvas set` remains an unconditional write, but participates in that serialization boundary so it cannot race a tagged save or deletion. Deletion classification is derived inside the datastore transaction; unrelated event kinds do not take the canvas lock. ## Read and timestamp consistency Precondition and post-write ancestry reads request strong consistency from `/query`, pinning them to the writer when replica routing is enabled. Display-only history remains replica-eligible. First-party canvas writers stamp `created_at = max(now, head + 1)` with a conservative client skew limit. The relay applies a canvas-specific future-timestamp ceiling before persistence, preventing an accepted boundary head from making subsequent first-party writes invalid while preserving the broader ingest limit for other event kinds. ## Durable outcomes and recovery After an accepted publish, a failed verification read is reported as durable success with `verified: false` rather than a failed save. Desktop and CLI preserve the new event ID and direct users to History when verification is unavailable. A successful read walks the bounded, cycle-guarded `expected-revision` ancestry chain so transitive descendants are not misclassified as supersession. Canvas caches invalidate for every settled mutation outcome, including a retained but superseded revision. The 409 reconciliation path in `cmd_restore_canvas` now separately establishes persistence evidence from ancestry classification. `canvas_write_survived` checks reachability only; a secondary writer-pinned IDs lookup distinguishes "A absent" from "A present but superseded by a legacy write." The four-way outcome (survived / persisted-but-superseded / genuinely-absent / read-fail) is now correct for all lost-response shapes including unconditional legacy writes. --------- Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz> Co-authored-by: Hayt <9e1c23a3fd83f61da34420e4e88ff1b16e45cafcc0cd9019eb07d4ecfa8ca9b0@buzz.block.builderlab.xyz>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -760,6 +760,10 @@ pub enum CanvasCmd {
|
||||
/// Channel UUID
|
||||
#[arg(long)]
|
||||
channel: String,
|
||||
/// Fetch a specific historical revision by event ID (64-char hex);
|
||||
/// defaults to the current head
|
||||
#[arg(long)]
|
||||
revision: Option<String>,
|
||||
},
|
||||
/// Set (replace) the canvas document for a channel
|
||||
Set {
|
||||
@@ -770,6 +774,24 @@ pub enum CanvasCmd {
|
||||
#[arg(long)]
|
||||
content: String,
|
||||
},
|
||||
/// List canvas revision history for a channel, newest first
|
||||
History {
|
||||
/// Channel UUID
|
||||
#[arg(long)]
|
||||
channel: String,
|
||||
/// Maximum number of revisions to return (1–10000)
|
||||
#[arg(long, default_value_t = 50, value_parser = clap::value_parser!(u32).range(1..=10_000))]
|
||||
limit: u32,
|
||||
},
|
||||
/// Restore the canvas to a previous revision by re-publishing its content
|
||||
Restore {
|
||||
/// Channel UUID
|
||||
#[arg(long)]
|
||||
channel: String,
|
||||
/// Revision event ID to restore (64-char hex)
|
||||
#[arg(long)]
|
||||
revision: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
@@ -2293,6 +2315,29 @@ mod tests {
|
||||
.is_err());
|
||||
}
|
||||
|
||||
/// `canvas history --limit` is bounded 1–10000 at parse time: zero and
|
||||
/// max+1 reject, the maximum is accepted, and the max stays above one
|
||||
/// 1,000-row relay page so the >1,000 pagination path remains reachable.
|
||||
#[test]
|
||||
fn canvas_history_limit_is_bounded() {
|
||||
let channel = "123e4567-e89b-12d3-a456-426614174000";
|
||||
let parse = |limit: &str| {
|
||||
Cli::try_parse_from([
|
||||
"buzz",
|
||||
"canvas",
|
||||
"history",
|
||||
"--channel",
|
||||
channel,
|
||||
"--limit",
|
||||
limit,
|
||||
])
|
||||
};
|
||||
assert!(parse("0").is_err(), "zero must reject");
|
||||
assert!(parse("10001").is_err(), "max+1 must reject");
|
||||
assert!(parse("10000").is_ok(), "maximum must be accepted");
|
||||
assert!(parse("1000").is_ok(), "one relay page must be reachable");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_status_clear_rejects_text_and_emoji() {
|
||||
for extra in [["--text", "busy"], ["--emoji", "🎶"]] {
|
||||
@@ -2428,7 +2473,10 @@ mod tests {
|
||||
"update"
|
||||
]
|
||||
);
|
||||
assert_eq!(names(&cmd, "canvas"), vec!["get", "set"]);
|
||||
assert_eq!(
|
||||
names(&cmd, "canvas"),
|
||||
vec!["get", "history", "restore", "set"]
|
||||
);
|
||||
assert_eq!(names(&cmd, "reactions"), vec!["add", "get", "remove"]);
|
||||
assert_eq!(
|
||||
names(&cmd, "emoji"),
|
||||
@@ -2532,7 +2580,7 @@ mod tests {
|
||||
fn subcommand_counts_are_stable() {
|
||||
let expected: Vec<(&str, usize)> = vec![
|
||||
("agents", 5),
|
||||
("canvas", 2),
|
||||
("canvas", 4),
|
||||
("channels", 16),
|
||||
("dms", 4),
|
||||
("emoji", 5),
|
||||
|
||||
Reference in New Issue
Block a user