mirror of
https://github.com/block/buzz.git
synced 2026-11-12 12:22: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:
43
desktop/src/shared/api/canvasTypes.ts
Normal file
43
desktop/src/shared/api/canvasTypes.ts
Normal 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;
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
const RELAY_QUERY_ROOTS = new Set<string>([
|
||||
"archivedIdentities",
|
||||
"channel-canvas",
|
||||
"channel-canvas-history",
|
||||
"channel-messages",
|
||||
"channels",
|
||||
"contact-list",
|
||||
|
||||
@@ -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> {
|
||||
|
||||
90
desktop/src/shared/api/tauriCanvas.ts
Normal file
90
desktop/src/shared/api/tauriCanvas.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user