From eb206b213b25f15af1404c679a74828217d20f38 Mon Sep 17 00:00:00 2001 From: Nate Spilman Date: Fri, 2 Oct 2026 21:26:19 -0700 Subject: [PATCH 1/3] lexicons: deterministic signup key, latest-wins submissions Signup.rkeyForRound hashes the round at-uri into a stable record key so a putRecord upserts instead of duplicating; signup.json key widens tid -> any. Submission.latestPerAuthor states the re-submission rule in code, and submission.json states it in prose. Not published. Co-Authored-By: Claude Opus 5.5 --- lexicons/at/atjam/signup.json | 4 ++-- lexicons/at/atjam/submission.json | 2 +- lexicons/src/signup.test.ts | 18 ++++++++++++++++++ lexicons/src/signup.ts | 18 ++++++++++++++++++ lexicons/src/submission.test.ts | 26 +++++++++++++++++++++++++- lexicons/src/submission.ts | 22 ++++++++++++++++++++++ 6 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 lexicons/src/signup.test.ts diff --git a/lexicons/at/atjam/signup.json b/lexicons/at/atjam/signup.json index e78f2c8..7ac59ab 100644 --- a/lexicons/at/atjam/signup.json +++ b/lexicons/at/atjam/signup.json @@ -1,12 +1,12 @@ { "lexicon": 1, "id": "at.atjam.signup", - "description": "A declaration of intent to participate in a specific round. The participant's DID is implicit (the record creator). When the round's joinMode is 'hosted' or 'network', a signup is only valid for readers when it strong-refs an at.atjam.invitation written by a valid inviter.", + "description": "A declaration of intent to participate in a specific round. The participant's DID is implicit (the record creator). When the round's joinMode is 'hosted' or 'network', a signup is only valid for readers when it strong-refs an at.atjam.invitation written by a valid inviter. Writers SHOULD key a signup by a hash of the round's at-uri (the first 32 hex chars of its SHA-256) and write it with putRecord, so a person has at most one signup per round; readers MUST still tolerate several signups from one DID (older TID-keyed records exist). Deleting a signup means the person has left the round.", "defs": { "main": { "type": "record", "description": "Declares the record creator's intent to participate in the referenced round.", - "key": "tid", + "key": "any", "record": { "type": "object", "required": ["round", "createdAt"], diff --git a/lexicons/at/atjam/submission.json b/lexicons/at/atjam/submission.json index 6e12af9..ce2ce05 100644 --- a/lexicons/at/atjam/submission.json +++ b/lexicons/at/atjam/submission.json @@ -1,7 +1,7 @@ { "lexicon": 1, "id": "at.atjam.submission", - "description": "A participant's submission to a round. atjam does not host the deliverable; it points at it. The strong way is `payload` — a strong-ref to a record on whichever ATProto app owns the data (plyr.fm track, standard.site post, etc.), which is content-addressed, verifiable, and portable. The fallback is `url` — a plain link, for deliverables that don't live on ATProto yet (an itch.io build, a hosted doc). Prefer payload; `url` is the humble alternative. The lexicon can't express \"one-of\" across fields, so both are optional here and the \"at least one\" rule is enforced in code (Submission.getDeliverable). The submitter's DID is implicit (the record creator).", + "description": "A participant's submission to a round. atjam does not host the deliverable; it points at it. The strong way is `payload` — a strong-ref to a record on whichever ATProto app owns the data (plyr.fm track, standard.site post, etc.), which is content-addressed, verifiable, and portable. The fallback is `url` — a plain link, for deliverables that don't live on ATProto yet (an itch.io build, a hosted doc). Prefer payload; `url` is the humble alternative. The lexicon can't express \"one-of\" across fields, so both are optional here and the \"at least one\" rule is enforced in code (Submission.getDeliverable). The submitter's DID is implicit (the record creator). Re-submission is \"latest wins\": a person may write several submissions to one round, and readers SHOULD treat only that author's newest (by createdAt) as their submission. Deleting a submission withdraws it.", "defs": { "main": { "type": "record", diff --git a/lexicons/src/signup.test.ts b/lexicons/src/signup.test.ts new file mode 100644 index 0000000..9ea91be --- /dev/null +++ b/lexicons/src/signup.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { rkeyForRound } from "./signup.js"; + +describe("rkeyForRound", () => { + it("is pinned: the same round always yields the same valid record key", async () => { + // If this changes, existing signups stop being overwritten and + // duplicates come back — treat a change here as a migration. + expect(await rkeyForRound("at://did:plc:org/at.atjam.round/3abc")).toBe( + "4c6bb43e214c3d2fd182dcc90abe43c8", + ); + }); + + it("differs for rounds sharing an rkey under different organizers", async () => { + expect(await rkeyForRound("at://did:plc:a/at.atjam.round/r1")).not.toBe( + await rkeyForRound("at://did:plc:b/at.atjam.round/r1"), + ); + }); +}); diff --git a/lexicons/src/signup.ts b/lexicons/src/signup.ts index 798f4f4..01b64c2 100644 --- a/lexicons/src/signup.ts +++ b/lexicons/src/signup.ts @@ -20,3 +20,21 @@ export interface Signup { /** Build a signup record to write: `$type` and `createdAt` filled in. */ export const build = (fields: RecordFields) => buildRecord(NSID, fields); + +/** + * The record key for a signup to `roundUri`: the first 32 hex chars + * (128 bits) of SHA-256 over the round's at-uri. One person, one round, + * one key — so writing it with putRecord overwrites a retry or double + * click instead of duplicating. Hashed because the round's own rkey is + * only unique within its organizer's repo. Changing this derivation would + * let duplicates back in; the test pins it. + */ +export async function rkeyForRound(roundUri: string): Promise { + const digest = await crypto.subtle.digest( + "SHA-256", + new TextEncoder().encode(roundUri), + ); + return [...new Uint8Array(digest).slice(0, 16)] + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/lexicons/src/submission.test.ts b/lexicons/src/submission.test.ts index 7b81690..c040a66 100644 --- a/lexicons/src/submission.test.ts +++ b/lexicons/src/submission.test.ts @@ -6,7 +6,12 @@ * content-addressed `payload` always wins over the weak, plain `url`. */ import { describe, expect, it } from "vitest"; -import { getDeliverable, hasDeliverable, type Submission } from "./submission.js"; +import { + getDeliverable, + hasDeliverable, + latestPerAuthor, + type Submission, +} from "./submission.js"; const round = { uri: "at://did:plc:org/at.atjam.round/r1", cid: "bafyR" }; const payloadRef = { uri: "at://did:plc:p/app.bsky.feed.post/p1", cid: "bafyP" }; @@ -49,3 +54,22 @@ describe("hasDeliverable", () => { expect(hasDeliverable(sub({}))).toBe(false); }); }); + +describe("latestPerAuthor", () => { + it("keeps each author's newest submission and marks it updated", () => { + const at = (did: string, rkey: string, createdAt: string) => ({ + uri: `at://${did}/at.atjam.submission/${rkey}`, + value: { createdAt }, + }); + const result = latestPerAuthor([ + at("did:plc:a", "2", "2026-01-02T00:00:00Z"), + at("did:plc:b", "1", "2026-01-01T00:00:00Z"), + at("did:plc:a", "1", "2026-01-01T00:00:00Z"), + at("did:plc:a", "3", "2026-01-03T00:00:00Z"), + ]); + expect(result.map((s) => [s.uri.split("/").pop(), s.updated])).toEqual([ + ["3", true], + ["1", false], + ]); + }); +}); diff --git a/lexicons/src/submission.ts b/lexicons/src/submission.ts index 8b202ca..011c5a5 100644 --- a/lexicons/src/submission.ts +++ b/lexicons/src/submission.ts @@ -1,5 +1,6 @@ import { buildRecord, type RecordFields } from "./record.js"; import type { StrongRef } from "./strong-ref.js"; +import { parseAtUri } from "./queries/at-uri.js"; export const NSID = "at.atjam.submission" as const; @@ -52,3 +53,24 @@ export function hasDeliverable(s: Submission): boolean { /** Build a submission record to write: `$type` and `createdAt` filled in. */ export const build = (fields: RecordFields) => buildRecord(NSID, fields); + +/** + * Re-submission is "latest wins": of each author's submissions to a round, + * only the newest (by createdAt) counts. Returns one entry per author, in + * the input's order of first appearance, with `updated` true when that + * author had earlier submissions. Older records are left alone, not deleted. + */ +export function latestPerAuthor< + S extends { uri: string; value: { createdAt: string } }, +>(submissions: readonly S[]): (S & { updated: boolean })[] { + const byAuthor = new Map(); + for (const s of submissions) { + const author = parseAtUri(s.uri).did; + const prev = byAuthor.get(author); + if (!prev) byAuthor.set(author, { ...s, updated: false }); + else if (s.value.createdAt > prev.value.createdAt) + byAuthor.set(author, { ...s, updated: true }); + else prev.updated = true; + } + return [...byAuthor.values()]; +} -- knotmirror 0.0.1 From 41fee9a7201249ae9e0b6464fa9693d673f73e26 Mon Sep 17 00:00:00 2001 From: Nate Spilman Date: Fri, 2 Oct 2026 21:30:04 -0700 Subject: [PATCH 2/3] web: upsert signups, read your own writes, and let people undo - Signups write with putRecord at Signup.rkeyForRound, so a retry or double click overwrites instead of duplicating. - useRecordWrite returns the written {uri, cid, record}; the round page merges pending writes and deletes over the lagging backlink lists (lib/pending), keyed by uri. - Leave round / Withdraw / Revoke delete the viewer's own records via deleteRecord, after a confirm. - Submissions render latest-per-author with an (updated) marker; the submit form says it replaces. - One line near signup and submit: records are public, in your account. Co-Authored-By: Claude Opus 5.5 --- web/app/components/invite-form.tsx | 11 ++- web/app/components/signup-button.tsx | 25 +++-- web/app/components/submit-form.tsx | 31 +++--- web/app/lib/pending.test.ts | 17 ++++ web/app/lib/pending.ts | 38 +++++++ web/app/lib/use-record-write.ts | 48 ++++++--- web/app/lib/writes.ts | 53 +++++++++- web/app/routes/round.$did.$rkey.tsx | 142 +++++++++++++++++++++++++-- 8 files changed, 314 insertions(+), 51 deletions(-) create mode 100644 web/app/lib/pending.test.ts create mode 100644 web/app/lib/pending.ts diff --git a/web/app/components/invite-form.tsx b/web/app/components/invite-form.tsx index c28db74..09790f2 100644 --- a/web/app/components/invite-form.tsx +++ b/web/app/components/invite-form.tsx @@ -1,7 +1,7 @@ import { useState, type FormEvent } from "react"; import { Invitation } from "@atjam/lexicons"; import { useSignedInAgent } from "~/lib/auth-context"; -import { useRecordWrite } from "~/lib/use-record-write"; +import { useRecordWrite, type WrittenRecord } from "~/lib/use-record-write"; import { resolveHandle } from "~/lib/handle"; import { Button } from "~/components/ui/button"; @@ -10,9 +10,11 @@ interface Props { // DIDs that already hold an invitation for this round — used for a soft // hint so the inviter doesn't issue an obvious duplicate. alreadyInvitedDids: string[]; + /** The written invitation, for the page to show before the index has it. */ + onInvited?: (written: WrittenRecord) => void; } -export function InviteForm({ round, alreadyInvitedDids }: Props) { +export function InviteForm({ round, alreadyInvitedDids, onInvited }: Props) { const myDid = useSignedInAgent()?.did ?? null; const { status, busy, run } = useRecordWrite(); const [input, setInput] = useState(""); @@ -26,7 +28,7 @@ export function InviteForm({ round, alreadyInvitedDids }: Props) { if (!input.trim()) return; const label = input.trim().replace(/^@/, ""); - const ok = await run({ + const written = await run({ // resolveHandle accepts a handle (resolving via the AppView) or a // bare DID (pass-through). Either way we get a DID for the invitee. resolve: () => resolveHandle(input), @@ -38,7 +40,8 @@ export function InviteForm({ round, alreadyInvitedDids }: Props) { }), }); - if (ok) { + if (written) { + onInvited?.(written); setInvitedLabel(label); setInput(""); setNote(""); diff --git a/web/app/components/signup-button.tsx b/web/app/components/signup-button.tsx index 00d1c46..f10e1c0 100644 --- a/web/app/components/signup-button.tsx +++ b/web/app/components/signup-button.tsx @@ -1,6 +1,6 @@ import { Queries, Signup } from "@atjam/lexicons"; import { useSignedInAgent } from "~/lib/auth-context"; -import { useRecordWrite } from "~/lib/use-record-write"; +import { useRecordWrite, type WrittenRecord } from "~/lib/use-record-write"; import { Button } from "~/components/ui/button"; const { parseAtUri } = Queries; @@ -11,11 +11,14 @@ interface Props { // authorizes this signup. Attached to the record so the signup // validates. Omitted for open rounds. invitation?: { uri: string; cid: string }; + /** The written signup, for the page to show before the index has it. */ + onSignedUp?: (written: WrittenRecord) => void; } export function SignupButton({ round, invitation, + onSignedUp, }: Props) { const myDid = useSignedInAgent()?.did ?? null; const { status, busy, run } = useRecordWrite(); @@ -26,18 +29,26 @@ export function SignupButton({
+

+ Your signup is public, and stored in your own account. +

{status.kind === "error" && (

{status.message} diff --git a/web/app/components/submit-form.tsx b/web/app/components/submit-form.tsx index ac24a72..0252b0b 100644 --- a/web/app/components/submit-form.tsx +++ b/web/app/components/submit-form.tsx @@ -1,7 +1,7 @@ import { useState, type FormEvent } from "react"; import { Queries, Submission } from "@atjam/lexicons"; import { useSignedInAgent } from "~/lib/auth-context"; -import { useRecordWrite } from "~/lib/use-record-write"; +import { useRecordWrite, type WrittenRecord } from "~/lib/use-record-write"; import { resolveSubmissionInput } from "~/lib/url-to-at-uri"; import { Button } from "~/components/ui/button"; @@ -11,12 +11,15 @@ interface Props { round: { uri: string; cid: string }; acceptedSubmissionTypes: string[]; alreadySubmittedDids: string[]; + /** The written submission, for the page to show before the index has it. */ + onSubmitted?: (written: WrittenRecord) => void; } export function SubmitForm({ round, acceptedSubmissionTypes, alreadySubmittedDids, + onSubmitted, }: Props) { const myDid = useSignedInAgent()?.did ?? null; const { status, busy, run } = useRecordWrite(); @@ -25,26 +28,24 @@ export function SubmitForm({ if (myDid === null) return null; - // Allow re-submission (an organizer might want a participant to update), - // but show a hint so it's not accidental. + // Re-submission is "latest wins" (see submission.json): a new record + // replaces the old one on the page. The old record isn't deleted. const alreadySubmitted = alreadySubmittedDids.includes(myDid); async function onSubmit(e: FormEvent) { e.preventDefault(); if (!input.trim()) return; - // Guard against accidental duplicate submissions — re-submitting adds - // another record rather than replacing, so confirm intent first. if ( alreadySubmitted && !window.confirm( - "You've already submitted to this round. Add another submission?", + "You've already submitted to this round. Replace your submission with this one?", ) ) { return; } - const ok = await run({ + const written = await run({ // Resolve the pasted input to a deliverable: a known record (gated to the // round's accepted types, fetched for its CID) or a plain url fallback. resolve: async (): Promise => { @@ -71,7 +72,8 @@ export function SubmitForm({ }), }); - if (ok) { + if (written) { + onSubmitted?.(written); setInput(""); setNote(""); } @@ -86,10 +88,13 @@ export function SubmitForm({ Submit your work {alreadySubmitted && ( - (you've already submitted — this will add another) + (you've already submitted — this will replace your submission) )}

+
+ Your submission is public, and stored in your own account. +
Accepts a record of{" "} {acceptedSubmissionTypes.map((nsid, i) => ( @@ -123,7 +128,9 @@ export function SubmitForm({ ? "checking…" : status.kind === "submitting" ? "submitting…" - : "submit"} + : alreadySubmitted + ? "replace" + : "submit"} {status.kind === "success" && ( @@ -133,8 +140,8 @@ export function SubmitForm({
{status.kind === "success" && (

- Recorded on your PDS. It appears in the round's submissions list - within a few seconds. Paste another link to add or update. + Recorded on your PDS. Paste another link to replace your + submission.

)} {status.kind === "error" && ( diff --git a/web/app/lib/pending.test.ts b/web/app/lib/pending.test.ts new file mode 100644 index 0000000..9deca34 --- /dev/null +++ b/web/app/lib/pending.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { mergePending } from "./pending"; + +describe("mergePending", () => { + const a = { uri: "at://did:plc:a/at.atjam.signup/1", from: "index" }; + const mine = { uri: "at://did:plc:me/at.atjam.signup/k", from: "pending" }; + + it("shows a pending write until the index echoes it, never twice", () => { + expect(mergePending([a], [mine], new Set())).toEqual([a, mine]); + const echo = { ...mine, from: "index" }; + expect(mergePending([a, echo], [mine], new Set())).toEqual([a, echo]); + }); + + it("keeps a deleted record hidden while the index still lists it", () => { + expect(mergePending([a, mine], [], new Set([mine.uri]))).toEqual([a]); + }); +}); diff --git a/web/app/lib/pending.ts b/web/app/lib/pending.ts new file mode 100644 index 0000000..261ed23 --- /dev/null +++ b/web/app/lib/pending.ts @@ -0,0 +1,38 @@ +import { useState } from "react"; + +/** + * Read-your-writes for a page whose lists come from a lagging index + * (Constellation trails the PDS by seconds). The viewer's own writes are + * held here and merged over the loader's list, keyed by at-uri: an added + * record shows until the index echoes it (then the echo is used — never + * both), and a deleted one stays hidden even while the index still has it. + */ +export function mergePending( + loaded: readonly T[], + added: readonly T[], + removed: ReadonlySet, +): T[] { + const seen = new Set(loaded.map((r) => r.uri)); + return [...loaded, ...added.filter((r) => !seen.has(r.uri))].filter( + (r) => !removed.has(r.uri), + ); +} + +/** Page-lifetime pending writes for one list. */ +export function usePending() { + const [added, setAdded] = useState([]); + const [removed, setRemoved] = useState>(new Set()); + return { + // A put can re-create a uri deleted earlier (leave, then rejoin), so + // adding un-hides it, and removing drops any pending copy. + add(item: T) { + setAdded((a) => [...a.filter((r) => r.uri !== item.uri), item]); + setRemoved((r) => new Set([...r].filter((u) => u !== item.uri))); + }, + remove(uri: string) { + setAdded((a) => a.filter((r) => r.uri !== uri)); + setRemoved((r) => new Set(r).add(uri)); + }, + merge: (loaded: readonly T[]) => mergePending(loaded, added, removed), + }; +} diff --git a/web/app/lib/use-record-write.ts b/web/app/lib/use-record-write.ts index 32d4b86..bc9f425 100644 --- a/web/app/lib/use-record-write.ts +++ b/web/app/lib/use-record-write.ts @@ -2,7 +2,7 @@ import { useState } from "react"; import { useRevalidator } from "react-router"; import { useAuth } from "./auth-context"; import type { RecordToWrite } from "@atjam/lexicons"; -import { createRecord } from "./writes"; +import { createRecord, putRecord, type CreatedRecord } from "./writes"; /** * The shared lifecycle of "write one record to my PDS, then refresh the @@ -20,26 +20,41 @@ export type WriteStatus = | { kind: "success" } | { kind: "error"; message: string }; -interface RunArgs { +/** What a successful write returns: the response plus the record sent. */ +export interface WrittenRecord extends CreatedRecord { + record: R; +} + +interface RunArgs { /** * Optional pre-write step (handle→DID, URL→at-uri, fetch a CID…). Status * is "resolving" while it runs; its result is handed to `build`. */ resolve?: () => Promise; /** - * Produce the record to create — usually a lexicon builder, e.g. - * `Signup.build({...})`. Status is "submitting" while the write is in - * flight. + * Produce the record to write — usually a lexicon builder, e.g. + * `Signup.build({...})`. With an `rkey` it's a putRecord (upsert at that + * key); without, a createRecord. Status is "submitting" while the write + * is in flight. */ - build: (resolved: T) => RecordToWrite | Promise>; + build: ( + resolved: T, + ) => + | (RecordToWrite & { rkey?: string }) + | Promise & { rkey?: string }>; } export interface UseRecordWrite { status: WriteStatus; /** True while resolving or submitting — for disabling the trigger. */ busy: boolean; - /** Run a write. Resolves true on success, false on guard-fail or error. */ - run: (args: RunArgs) => Promise; + /** + * Run a write. Resolves the written record on success, null on guard-fail + * or error — callers merge it into the page until the index catches up. + */ + run: ( + args: RunArgs, + ) => Promise | null>; reset: () => void; } @@ -51,8 +66,10 @@ export function useRecordWrite(opts?: { const revalidator = useRevalidator(); const [status, setStatus] = useState({ kind: "idle" }); - async function run(args: RunArgs): Promise { - if (state.status !== "signed-in") return false; + async function run( + args: RunArgs, + ): Promise | null> { + if (state.status !== "signed-in") return null; try { let resolved = undefined as T; if (args.resolve) { @@ -60,17 +77,20 @@ export function useRecordWrite(opts?: { resolved = await args.resolve(); } setStatus({ kind: "submitting" }); - const { collection, record } = await args.build(resolved); - await createRecord({ agent: state.agent, collection, record }); + const { collection, record, rkey } = await args.build(resolved); + const agent = state.agent; + const ref = rkey + ? await putRecord({ agent, collection, record, rkey }) + : await createRecord({ agent, collection, record }); if (opts?.revalidate ?? true) revalidator.revalidate(); setStatus({ kind: "success" }); - return true; + return { ...ref, record }; } catch (err) { setStatus({ kind: "error", message: err instanceof Error ? err.message : String(err), }); - return false; + return null; } } diff --git a/web/app/lib/writes.ts b/web/app/lib/writes.ts index 496e4b3..9920911 100644 --- a/web/app/lib/writes.ts +++ b/web/app/lib/writes.ts @@ -1,4 +1,5 @@ -import type { Did, Nsid } from "@atcute/lexicons/syntax"; +import type { Did, Nsid, RecordKey } from "@atcute/lexicons/syntax"; +import { Queries } from "@atjam/lexicons"; import type { ResumedAgent } from "./oauth"; export interface CreatedRecord { @@ -6,6 +7,12 @@ export interface CreatedRecord { cid: string; } +function narrow(data: unknown, method: string): CreatedRecord { + const d = data as { uri?: string; cid?: string } | undefined; + if (!d?.uri || !d?.cid) throw new Error(`${method} response missing uri/cid`); + return { uri: d.uri, cid: d.cid }; +} + // Thin wrapper around com.atproto.repo.createRecord. The atcute Client returns // a typed response; we narrow to the fields we need everywhere downstream. export async function createRecord(args: { @@ -22,9 +29,45 @@ export async function createRecord(args: { record: args.record as Record, }, }); - const data = response.data as { uri?: string; cid?: string } | undefined; - if (!data?.uri || !data?.cid) { - throw new Error("createRecord response missing uri/cid"); + return narrow(response.data, "createRecord"); +} + +// com.atproto.repo.putRecord: create-or-overwrite at a known rkey. For records +// with one-per-thing cardinality (a signup per round), so a retry upserts. +export async function putRecord(args: { + agent: ResumedAgent; + collection: string; + record: object; + rkey: string; +}): Promise { + const response = await args.agent.rpc.post("com.atproto.repo.putRecord", { + input: { + repo: args.agent.did as Did, + collection: args.collection as Nsid, + rkey: args.rkey as RecordKey, + record: args.record as Record, + }, + }); + return narrow(response.data, "putRecord"); +} + +// com.atproto.repo.deleteRecord, addressed by at-uri. Only the agent's own +// records can be deleted — the uri's repo must be the agent's DID. +export async function deleteRecord(args: { + agent: ResumedAgent; + uri: string; +}): Promise { + const { did, collection, rkey } = Queries.parseAtUri(args.uri); + if (did !== args.agent.did) throw new Error("can only delete your own records"); + const response = await args.agent.rpc.post("com.atproto.repo.deleteRecord", { + input: { + repo: did as Did, + collection: collection as Nsid, + rkey: rkey as RecordKey, + }, + }); + if (!response.ok) { + const { message } = response.data as { message?: string }; + throw new Error(message ?? "deleteRecord failed"); } - return { uri: data.uri, cid: data.cid }; } diff --git a/web/app/routes/round.$did.$rkey.tsx b/web/app/routes/round.$did.$rkey.tsx index c53f10a..f3e0f1f 100644 --- a/web/app/routes/round.$did.$rkey.tsx +++ b/web/app/routes/round.$did.$rkey.tsx @@ -1,5 +1,13 @@ -import { Link } from "react-router"; -import { Eligibility, NSIDS, Queries, Round, Signup, Validate } from "@atjam/lexicons"; +import { Link, useRevalidator } from "react-router"; +import { + Eligibility, + NSIDS, + Queries, + Round, + Signup, + Submission, + Validate, +} from "@atjam/lexicons"; import { timeUntil, useDateFormat } from "~/lib/format"; import { SignupButton, @@ -20,6 +28,9 @@ import { buildDeliverablePreview } from "~/lib/payload-preview"; import { JOIN_MODE_GLYPHS } from "~/lib/round-visuals"; import { subjectCredit } from "~/lib/subject-credit"; import { useAuth } from "~/lib/auth-context"; +import { usePending } from "~/lib/pending"; +import { deleteRecord } from "~/lib/writes"; +import { Button } from "~/components/ui/button"; import type { Route } from "./+types/round.$did.$rkey"; const { @@ -38,6 +49,9 @@ type ValidatedSignup = { value: Signup.Signup; validation: { valid: boolean; reason?: string }; }; +type LoaderData = Route.ComponentProps["loaderData"]; +type SubmissionRow = LoaderData["submissions"][number]; +type InvitationRow = LoaderData["invitations"][number]; export const meta: Route.MetaFunction = ({ data }) => { if (!data?.round?.value) return [{ title: "Round · atjam" }]; @@ -224,8 +238,19 @@ function joinPolicyNote( } export default function RoundDetail({ loaderData }: Route.ComponentProps) { - const { round, jam, signups, submissions, invitations, handles } = - loaderData; + const { round, jam, handles } = loaderData; + // Read-your-writes: the viewer's own writes and deletes ride over the + // loader's lists until Constellation catches up, so a fresh signup + // doesn't vanish (and bring "I'm in" back) on the first revalidate. + const pendingSignups = usePending(); + const pendingSubmissions = usePending(); + const pendingInvitations = usePending(); + const signups = pendingSignups.merge(loaderData.signups); + const allSubmissions = pendingSubmissions.merge(loaderData.submissions); + const invitations = pendingInvitations.merge(loaderData.invitations); + // Re-submission is "latest wins": one entry per author. + const submissions = Submission.latestPerAuthor(allSubmissions); + const revalidator = useRevalidator(); const state = Round.deriveState(round.value); const joinMode = Round.getJoinMode(round.value); @@ -250,6 +275,27 @@ export default function RoundDetail({ loaderData }: Route.ComponentProps) { const myDid = auth.state.status === "signed-in" ? auth.state.agent.did : null; const isOrganizer = myDid !== null && myDid === organizerDid; + const isMine = (uri: string) => myDid !== null && parseAtUri(uri).did === myDid; + const mySignupUris = signups.map((s) => s.uri).filter(isMine); + + // Undo: delete the viewer's own records, after a confirm. Each deleted + // uri is hidden at once and stays hidden while the index lags. + async function undo( + question: string, + uris: string[], + pending: { remove: (uri: string) => void }, + ) { + if (auth.state.status !== "signed-in" || !window.confirm(question)) return; + try { + for (const uri of uris) { + await deleteRecord({ agent: auth.state.agent, uri }); + pending.remove(uri); + } + } catch (err) { + window.alert(err instanceof Error ? err.message : String(err)); + } + revalidator.revalidate(); + } // The round's join policy decides where the viewer stands, who may invite, // and which invitation would make a signup valid. Those rules live in @@ -368,21 +414,53 @@ export default function RoundDetail({ loaderData }: Route.ComponentProps) { cid: standing.invitation.cid, } } + // Shown as valid: the button only appears when the viewer + // holds a usable invitation (or the round is open). + onSignedUp={(w) => + pendingSignups.add({ + uri: w.uri, + value: w.record, + validation: { valid: true }, + }) + } /> )} {standing?.kind === "signed-up" && ( <> -

- ✓ You're signed up. - {standing.canSubmit && " Submit your work below when ready."} -

+
+

+ ✓ You're signed up. + {standing.canSubmit && " Submit your work below when ready."} +

+ +
{standing.canSubmit && ( + pendingSubmissions.add({ + uri: w.uri, + cid: w.cid, + value: w.record, + preview: await buildDeliverablePreview(w.record), + }) + } /> )} @@ -411,6 +489,13 @@ export default function RoundDetail({ loaderData }: Route.ComponentProps) { + pendingInvitations.add({ + uri: w.uri, + cid: w.cid, + value: w.record, + }) + } /> )} @@ -493,6 +578,22 @@ export default function RoundDetail({ loaderData }: Route.ComponentProps) { organizer )} + {isMine(inv.uri) && ( + + )} ); @@ -565,8 +666,31 @@ export default function RoundDetail({ loaderData }: Route.ComponentProps) { key={s.uri} className="rounded border border-line p-4" > -
+
+ {s.updated && ( + + (updated) + + )} + {isMine(s.uri) && ( + + )}
{s.value.note && (

{s.value.note}

-- knotmirror 0.0.1 From 1fa51d7ec98bd1a5c178946496e39b7a91fffa64 Mon Sep 17 00:00:00 2001 From: Nate Spilman Date: Fri, 2 Oct 2026 21:30:09 -0700 Subject: [PATCH 3/3] web: delete the orphan jam when one-shot round creation fails applyWrites can't make jam+round atomic: the round's strong-ref needs the jam's CID, which only exists once the jam is written. Keep two calls and roll back the jam if the round write throws. Co-Authored-By: Claude Opus 5.5 --- web/app/routes/new.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/web/app/routes/new.tsx b/web/app/routes/new.tsx index 6604d48..0556557 100644 --- a/web/app/routes/new.tsx +++ b/web/app/routes/new.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, type FormEvent } from "react"; import { useNavigate } from "react-router"; import { Jam, Queries, Round } from "@atjam/lexicons"; import { useAuth } from "~/lib/auth-context"; -import { createRecord } from "~/lib/writes"; +import { createRecord, deleteRecord } from "~/lib/writes"; import { Button } from "~/components/ui/button"; import { Notice } from "~/components/notice"; @@ -102,6 +102,11 @@ export default function NewRound() { } setSubmitting(true); + // One-shot writes two records. applyWrites can't make them atomic: the + // round's strong-ref needs the jam's CID, which only the write returns. + // So if the round fails, delete the jam we just made rather than leave + // an orphan. + let createdJamUri: string | null = null; try { let jamRef: { uri: string; cid: string }; @@ -110,6 +115,7 @@ export default function NewRound() { agent: state.agent, ...Jam.build({ name: newJamName.trim() }), }); + createdJamUri = jamRef.uri; } else { const found = jams.find((j) => j.uri === existingJamUri); if (!found) throw new Error("selected jam not found in your records"); @@ -132,6 +138,11 @@ export default function NewRound() { const { did: rDid, rkey: rRkey } = parseAtUri(round.uri); navigate(`/round/${rDid}/${rRkey}`); } catch (err) { + if (createdJamUri) { + await deleteRecord({ agent: state.agent, uri: createdJamUri }).catch( + () => {}, // best effort; the round error is the one to show + ); + } setError(err instanceof Error ? err.message : String(err)); } finally { setSubmitting(false); -- knotmirror 0.0.1