mirror of
https://github.com/block/buzz.git
synced 2026-10-31 14:31:02 +00:00
🤖 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>
91 lines
2.2 KiB
TypeScript
91 lines
2.2 KiB
TypeScript
const RELAY_QUERY_ROOTS = new Set<string>([
|
|
"archivedIdentities",
|
|
"channel-canvas",
|
|
"channel-canvas-history",
|
|
"channel-messages",
|
|
"channels",
|
|
"contact-list",
|
|
"custom-emoji",
|
|
"custom-emoji-own",
|
|
"forum-posts",
|
|
"forum-thread",
|
|
"global-notes",
|
|
"home-feed",
|
|
"liked-notes",
|
|
"my-notes",
|
|
"myRelayMembership",
|
|
"oaOwner",
|
|
"presence",
|
|
"profile",
|
|
"pulse-note",
|
|
"pulse-reactions",
|
|
"pulse-timeline",
|
|
"relay-agents",
|
|
"relayMembers",
|
|
"reminders",
|
|
"run-approvals",
|
|
"search-messages",
|
|
"thread-replies",
|
|
"user-profile",
|
|
"user-search",
|
|
"user-status",
|
|
"users-batch",
|
|
"workflow",
|
|
"workflow-runs",
|
|
"workflows",
|
|
"workflows-all",
|
|
]);
|
|
|
|
const RELAY_PROJECT_QUERY_PARTS = new Set<string>([
|
|
"activity-summaries",
|
|
"home-channel",
|
|
"issues",
|
|
"pull-requests",
|
|
// Fresh for two minutes (PROJECT_WORK_ITEMS_STALE_TIME_MS) and tolerant of
|
|
// partial fan-out failure — reconnect auto-heal is what repairs a partial
|
|
// result inside that window.
|
|
"work-items",
|
|
]);
|
|
|
|
const LOCAL_PROJECT_QUERY_PARTS = new Set<string>([
|
|
"commit-diff",
|
|
"local-repo-diff",
|
|
"local-repo-snapshot",
|
|
"local-repositories",
|
|
"repo-diff",
|
|
"repo-snapshot",
|
|
"repo-state",
|
|
"repo-sync-status",
|
|
]);
|
|
|
|
function isRelayDependentProjectQueryKey(queryKey: readonly unknown[]) {
|
|
if (queryKey[0] === "projects") {
|
|
const scope = queryKey[1];
|
|
if (scope === undefined) return true;
|
|
if (typeof scope !== "string") return false;
|
|
if (LOCAL_PROJECT_QUERY_PARTS.has(scope)) return false;
|
|
return RELAY_PROJECT_QUERY_PARTS.has(scope);
|
|
}
|
|
|
|
if (queryKey[0] === "project") {
|
|
const scope = queryKey[2];
|
|
if (scope === undefined) return true;
|
|
if (typeof scope !== "string") return false;
|
|
if (LOCAL_PROJECT_QUERY_PARTS.has(scope)) return false;
|
|
return RELAY_PROJECT_QUERY_PARTS.has(scope);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
export function isRelayDependentQueryKey(queryKey: readonly unknown[]) {
|
|
const root = queryKey[0];
|
|
if (typeof root !== "string") return false;
|
|
if (RELAY_QUERY_ROOTS.has(root)) return true;
|
|
return isRelayDependentProjectQueryKey(queryKey);
|
|
}
|
|
|
|
export function isRelayDependentQuery(query: { queryKey: readonly unknown[] }) {
|
|
return isRelayDependentQueryKey(query.queryKey);
|
|
}
|