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:
Will Pfleger
2026-09-23 11:03:54 -04:00
committed by GitHub
parent ec7ea38f62
commit b6a26556ec
48 changed files with 9932 additions and 186 deletions

View File

@@ -0,0 +1,43 @@
export type CanvasResponse = {
content: string | null;
eventId: string | null;
updatedAt: number | null;
author: string | null;
};
export type SetCanvasInput = {
channelId: string;
content: string;
expectedRevision?: string | null;
};
export type SetCanvasResult = {
ok: boolean;
eventId: string;
/**
* `false` when the write was accepted by the relay but the post-write
* verification read failed, so supersession could not be checked. The save is
* durable; the caller shows a non-destructive "saved, verification
* unavailable" note rather than a failure. `true` on the normal verified path.
*/
verified: boolean;
};
export type CanvasRevision = {
eventId: string;
content: string;
createdAt: number;
author: string;
};
/** Composite `(created_at DESC, id ASC)` cursor for "Load older" paging. */
export type CanvasHistoryCursor = {
createdAt: number;
eventId: string;
};
export type CanvasHistoryResponse = {
revisions: CanvasRevision[];
/** Present only when older revisions may remain. */
nextCursor: CanvasHistoryCursor | null;
};

View File

@@ -1,6 +1,7 @@
const RELAY_QUERY_ROOTS = new Set<string>([
"archivedIdentities",
"channel-canvas",
"channel-canvas-history",
"channel-messages",
"channels",
"contact-list",

View File

@@ -8,7 +8,6 @@ import type {
AddChannelMembersResult,
BackendProviderCandidate,
BackendProviderProbeResult,
CanvasResponse,
GetHomeFeedInput,
HomeFeedResponse,
ManagedAgent,
@@ -21,8 +20,6 @@ import type {
RelayEvent,
SearchMessagesInput,
SearchMessagesResponse,
SetCanvasInput,
SetCanvasResult,
ThreadCursor,
ThreadRepliesResponse,
CreateManagedAgentInput,
@@ -38,6 +35,11 @@ import type {
} from "@/shared/api/types";
export * from "@/shared/api/tauriChannels";
export {
getCanvas,
getCanvasHistory,
setCanvas,
} from "@/shared/api/tauriCanvas";
export { sendChannelMessage } from "@/shared/api/tauriMessages";
export { getEventById, getEventsByIds } from "@/shared/api/tauriEvents";
@@ -230,17 +232,6 @@ type RawListRelayMembersResponse = {
members: RawRelayMember[];
};
type RawCanvasResponse = {
content: string | null;
updated_at: number | null;
author: string | null;
};
type RawSetCanvasResult = {
ok: boolean;
event_id: string;
};
/** Error normalized from a rejected Tauri invocation with its wire payload. */
export class TauriInvokeError extends Error {
readonly payload: unknown;
@@ -383,33 +374,6 @@ export async function leaveChannel(channelId: string): Promise<void> {
await invokeTauri("leave_channel", { channelId });
}
export async function getCanvas(channelId: string): Promise<CanvasResponse> {
const response = await invokeTauri<RawCanvasResponse>("get_canvas", {
channelId,
});
return {
content: response.content,
// Normalize absent keys to null: ensureWelcomeCanvas treats null as
// "no canvas yet", and `undefined !== null` would make every fresh
// channel look already-seeded.
updatedAt: response.updated_at ?? null,
author: response.author ?? null,
};
}
export async function setCanvas(
input: SetCanvasInput,
): Promise<SetCanvasResult> {
const response = await invokeTauri<RawSetCanvasResult>("set_canvas", {
channelId: input.channelId,
content: input.content,
});
return {
ok: response.ok,
eventId: response.event_id,
};
}
export async function getHomeFeed(
input: GetHomeFeedInput = {},
): Promise<HomeFeedResponse> {

View File

@@ -0,0 +1,90 @@
import { invokeTauri } from "@/shared/api/tauri";
import type {
CanvasHistoryCursor,
CanvasHistoryResponse,
CanvasResponse,
SetCanvasInput,
SetCanvasResult,
} from "@/shared/api/canvasTypes";
type RawCanvasResponse = {
content: string | null;
event_id: string | null;
updated_at: number | null;
author: string | null;
};
type RawCanvasHistoryResponse = {
revisions: {
event_id: string;
content: string;
created_at: number;
author: string;
}[];
next_cursor: { created_at: number; event_id: string } | null;
};
type RawSetCanvasResult = {
ok: boolean;
event_id: string;
verified: boolean;
};
export async function getCanvas(channelId: string): Promise<CanvasResponse> {
const response = await invokeTauri<RawCanvasResponse>("get_canvas", {
channelId,
});
return {
content: response.content,
eventId: response.event_id ?? null,
// Normalize absent keys to null: ensureWelcomeCanvas treats null as
// "no canvas yet", and `undefined !== null` would make every fresh
// channel look already-seeded.
updatedAt: response.updated_at ?? null,
author: response.author ?? null,
};
}
export async function setCanvas(
input: SetCanvasInput,
): Promise<SetCanvasResult> {
const response = await invokeTauri<RawSetCanvasResult>("set_canvas", {
channelId: input.channelId,
content: input.content,
expectedRevision: input.expectedRevision ?? null,
});
return {
ok: response.ok,
eventId: response.event_id,
verified: response.verified,
};
}
export async function getCanvasHistory(
channelId: string,
options: { limit?: number; cursor?: CanvasHistoryCursor | null } = {},
): Promise<CanvasHistoryResponse> {
const response = await invokeTauri<RawCanvasHistoryResponse>(
"get_canvas_history",
{
channelId,
limit: options.limit ?? null,
until: options.cursor?.createdAt ?? null,
beforeId: options.cursor?.eventId ?? null,
},
);
return {
revisions: response.revisions.map((revision) => ({
eventId: revision.event_id,
content: revision.content,
createdAt: revision.created_at,
author: revision.author,
})),
nextCursor: response.next_cursor
? {
createdAt: response.next_cursor.created_at,
eventId: response.next_cursor.event_id,
}
: null,
};
}

View File

@@ -69,21 +69,15 @@ export type SetChannelPurposeInput = {
purpose: string;
};
export type CanvasResponse = {
content: string | null;
updatedAt: number | null;
author: string | null;
};
export type {
CanvasHistoryCursor,
CanvasHistoryResponse,
CanvasResponse,
CanvasRevision,
SetCanvasInput,
SetCanvasResult,
} from "@/shared/api/canvasTypes";
export type SetCanvasInput = {
channelId: string;
content: string;
};
export type SetCanvasResult = {
ok: boolean;
eventId: string;
};
export type AddChannelMembersInput = {
channelId: string;
pubkeys: string[];