Files
buzz/desktop/src/shared/api/relayQueryInvalidation.ts
Will Pfleger b6a26556ec 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>
2026-09-23 11:03:54 -04:00

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);
}