From 5b8360effaad56d5fc1279a504660f190a2e6490 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Sat, 22 Aug 2026 19:11:07 +0200 Subject: [PATCH 1/3] feat: build the two events that withdraw a document --- apps/web/app/lib/corpus-store.test.ts | 25 ++++++++- apps/web/app/lib/corpus-store.ts | 11 ++-- apps/web/app/lib/corpus.ts | 19 ++++++- packages/nostr/src/index.ts | 2 + packages/nostr/src/spec.ts | 39 ++++++++++++++ packages/nostr/test/spec.test.ts | 74 ++++++++++++++++++++++++++- 6 files changed, 164 insertions(+), 6 deletions(-) diff --git a/apps/web/app/lib/corpus-store.test.ts b/apps/web/app/lib/corpus-store.test.ts index c77ac65..7aad3d4 100644 --- a/apps/web/app/lib/corpus-store.test.ts +++ b/apps/web/app/lib/corpus-store.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { mergeDocs } from "./corpus-store"; +import { mergeDocs, withoutDoc } from "./corpus-store"; import type { SearchDoc } from "./search"; const doc = (identifier: string, revisedAt: number, title = identifier): SearchDoc => ({ @@ -45,3 +45,26 @@ describe("mergeDocs", () => { expect(mergeDocs([], [])).toEqual([]); }); }); + +describe("withoutDoc", () => { + const author = "a".repeat(64); + + it("drops the document at that coordinate and leaves the rest", () => { + expect(titles(withoutDoc([doc("a", 1), doc("b", 1)], author, "a"))).toEqual(["b"]); + }); + + it("leaves another author's document of the same identifier alone", () => { + const theirs = { ...doc("shared", 1, "theirs"), pubkey: "b".repeat(64) }; + expect(titles(withoutDoc([doc("shared", 1, "mine"), theirs], author, "shared"))).toEqual([ + "theirs", + ]); + }); + + it("drops every revision held for it, not the newest one", () => { + expect(withoutDoc([doc("a", 1), doc("a", 2)], author, "a")).toEqual([]); + }); + + it("is nothing to do when the document was never stored", () => { + expect(titles(withoutDoc([doc("a", 1)], author, "gone"))).toEqual(["a"]); + }); +}); diff --git a/apps/web/app/lib/corpus-store.ts b/apps/web/app/lib/corpus-store.ts index 6307291..7c4ffe3 100644 --- a/apps/web/app/lib/corpus-store.ts +++ b/apps/web/app/lib/corpus-store.ts @@ -5,9 +5,10 @@ import type { SearchDoc } from "./search"; * Where the corpus waits between visits, so a reader who has already searched * once does not download every document again to search a second time. * - * A deletion is not carried: nothing here reacts to a kind 5, so a document its - * author retracted stays searchable until this store is dropped. Relays are the - * source of truth, and this is a cache that admits it. + * A document withdrawn in this browser is dropped from it, and nothing else is: + * no kind 5 is read here, so a document somebody else retracted stays searchable + * until a walk stops finding it. Relays are the source of truth, and this is a + * cache that admits it. */ const DB = "openspecs"; const VERSION = 1; @@ -34,6 +35,10 @@ export const mergeDocs = (existing: SearchDoc[], incoming: SearchDoc[]): SearchD return [...live.values()]; }; +/** The one document at a coordinate, gone. Its author withdrew it. */ +export const withoutDoc = (docs: SearchDoc[], pubkey: string, identifier: string): SearchDoc[] => + docs.filter((doc) => coordinateOf(doc) !== `${pubkey}:${identifier}`); + const open = (): Promise => new Promise((resolve, reject) => { const request = indexedDB.open(DB, VERSION); diff --git a/apps/web/app/lib/corpus.ts b/apps/web/app/lib/corpus.ts index 6233bf6..967a74b 100644 --- a/apps/web/app/lib/corpus.ts +++ b/apps/web/app/lib/corpus.ts @@ -1,5 +1,5 @@ import type { NostrEvent, Spec } from "@openspecs/nostr"; -import { mergeDocs, readStored, writeStored } from "./corpus-store"; +import { mergeDocs, readStored, withoutDoc, writeStored } from "./corpus-store"; import type { SearchDoc } from "./search"; export type CorpusStatus = "idle" | "loading" | "syncing" | "ready" | "failed"; @@ -140,3 +140,20 @@ export const rememberSpec = async (event: NostrEvent): Promise => { const stored = await readStored(); await writeStored(mergeDocs(stored.docs, [doc]), stored.cursors); }; + +/** + * A document its author just withdrew. Written back the same way, and for the + * same reason: a walk that has not started yet reads from disk and would put + * back what this took out of memory. + * + * A later walk may find it again on a relay that kept serving it, and it should: + * this searches what the relays hold, and correcting them here would be an index + * that disagrees with every page it links to. + */ +export const forgetSpec = async (pubkey: string, identifier: string): Promise => { + docs = withoutDoc(docs, pubkey, identifier); + if (started) publish(state.status); + + const stored = await readStored(); + await writeStored(withoutDoc(stored.docs, pubkey, identifier), stored.cursors); +}; diff --git a/packages/nostr/src/index.ts b/packages/nostr/src/index.ts index 60377c9..61b8bee 100644 --- a/packages/nostr/src/index.ts +++ b/packages/nostr/src/index.ts @@ -190,6 +190,7 @@ export { } from "./relay"; export { buildSpec, + buildSpecDeletion, editSpec, parseSpec, type Spec, @@ -201,4 +202,5 @@ export { specDraftOf, specFaults, toIdentifier, + withdrawSpec, } from "./spec"; diff --git a/packages/nostr/src/spec.ts b/packages/nostr/src/spec.ts index eeadaf5..b91a15f 100644 --- a/packages/nostr/src/spec.ts +++ b/packages/nostr/src/spec.ts @@ -8,6 +8,7 @@ import { } from "./event"; import { deriveSummary, firstHeading } from "./markdown"; import { CLIENT_NAME } from "./nip22"; +import { DELETION_KIND } from "./nip25"; export type SpecKindRef = { /** The `k` value exactly as published. Not always a number. */ @@ -269,6 +270,44 @@ export const editSpec = (live: NostrEvent | null, draft: SpecDraft): EventDraft /** The same, for a document nobody has published yet: there is no live revision to read first. */ export const buildSpec = (draft: SpecDraft): EventDraft => editSpec(null, draft); +/** + * The empty revision an author replaces their own document with, and the first + * half of withdrawing it. + * + * Nothing of the live event is carried over, which is the whole point and also + * why this needs no live event to build: a `d` is what makes the address, and + * everything else was the document. A relay that never honours the deletion + * request below keeps serving this instead, and what it serves is blank rather + * than the document. `isEmpty` is then what drops it from every listing. + */ +export const withdrawSpec = (identifier: string): EventDraft => ({ + kind: SPEC_KIND, + content: "", + tags: [ + ["d", identifier.trim()], + ["client", CLIENT_NAME], + ], +}); + +/** + * NIP-09, and the second half: the request that the revisions above be forgotten. + * + * An `a` and never an `e`. A relay honouring `a` drops every revision at the + * coordinate up to this request's `created_at`, the empty one included, and the + * document 404s. A relay that only understands `e` would instead drop whichever + * single revision was named and leave the one before it live, so naming the + * empty revision here would republish the document it was sent to withdraw. + */ +export const buildSpecDeletion = (coordinate: string): EventDraft => ({ + kind: DELETION_KIND, + content: "", + tags: [ + ["a", coordinate], + ["k", String(SPEC_KIND)], + ["client", CLIENT_NAME], + ], +}); + /** * A `d` derived from a title, which the schema allows in as many words. Only * ever a suggestion for a field that stays editable: an identifier somebody diff --git a/packages/nostr/test/spec.test.ts b/packages/nostr/test/spec.test.ts index 60a798b..63ddee9 100644 --- a/packages/nostr/test/spec.test.ts +++ b/packages/nostr/test/spec.test.ts @@ -1,14 +1,19 @@ import { finalizeEvent, generateSecretKey, getPublicKey, verifyEvent } from "nostr-tools/pure"; import { describe, expect, it } from "vitest"; -import { SPEC_KIND } from "../src/event"; +import { parseCoordinate, toCoordinate } from "../src/address"; +import { SPEC_KIND, tagValue } from "../src/event"; +import { latestByCoordinate } from "../src/relay"; import { buildSpec, + buildSpecDeletion, editSpec, parseSpec, + type Spec, type SpecDraft, specDraftOf, specFaults, toIdentifier, + withdrawSpec, } from "../src/spec"; import { caseEvents, events } from "./fixtures"; @@ -465,6 +470,73 @@ describe("buildSpec", () => { }); }); +describe("withdrawSpec", () => { + it("writes an address and nothing that was ever the document", () => { + expect(withdrawSpec("x")).toEqual({ + kind: SPEC_KIND, + content: "", + tags: [ + ["d", "x"], + ["client", "openspecs"], + ], + }); + }); + + it("carries none of a live revision, whatever that revision held", () => { + for (const event of specs) { + const identifier = parseSpec(event)?.identifier as string; + const withdrawn = withdrawSpec(identifier); + expect(withdrawn.tags.map((tag) => tag[0])).toEqual(["d", "client"]); + expect(withdrawn.content).toBe(""); + } + }); + + it("parses as the same document, emptied, so it replaces rather than adds one", () => { + const live = specEvent( + [ + ["d", "here"], + ["title", "Here"], + ], + "# Here\n\nSomething.", + ); + const withdrawn = parseSpec(sign(withdrawSpec("here"), live.created_at + 1)); + expect(withdrawn?.identifier).toBe("here"); + expect(withdrawn?.pubkey).toBe(parseSpec(live)?.pubkey); + expect(withdrawn?.isEmpty).toBe(true); + expect(latestByCoordinate([parseSpec(live) as Spec, withdrawn as Spec])).toEqual([withdrawn]); + }); + + it("trims the identifier, as every other builder here does", () => { + expect(named(withdrawSpec(" spaced "), "d")).toEqual([["d", "spaced"]]); + }); +}); + +describe("buildSpecDeletion", () => { + it("asks for a coordinate, and says which kind it names", () => { + expect(buildSpecDeletion("30817:abc:x")).toEqual({ + kind: 5, + content: "", + tags: [ + ["a", "30817:abc:x"], + ["k", "30817"], + ["client", "openspecs"], + ], + }); + }); + + // A relay that only understands `e` would drop the revision named and leave + // the one before it live, which republishes the document being withdrawn. + it("names no event id", () => { + expect(named(buildSpecDeletion("30817:abc:x"), "e")).toEqual([]); + }); + + it("round trips a coordinate holding colons of its own", () => { + const pointer = { pubkey: "a".repeat(64), identifier: "stele:space:stele-test-docs" }; + const coordinate = tagValue(sign(buildSpecDeletion(toCoordinate(pointer))), "a"); + expect(parseCoordinate(coordinate)).toEqual({ ...pointer, relays: [] }); + }); +}); + describe("toIdentifier", () => { it("produces the identifiers these documents were actually filed under", () => { expect(toIdentifier("Gleason's NIP-01")).toBe("gleasons-nip-01"); From e9d33cb974e613ead63b6c3498d4d4758f5d060a Mon Sep 17 00:00:00 2001 From: Nogringo Date: Sat, 22 Aug 2026 20:02:31 +0200 Subject: [PATCH 2/3] feat: let an author withdraw a document they published --- apps/web/app/components/editor/withdraw.tsx | 221 ++++++++++++++++++++ apps/web/app/lib/corpus-store.ts | 9 +- apps/web/app/routes/spec.tsx | 31 ++- 3 files changed, 247 insertions(+), 14 deletions(-) create mode 100644 apps/web/app/components/editor/withdraw.tsx diff --git a/apps/web/app/components/editor/withdraw.tsx b/apps/web/app/components/editor/withdraw.tsx new file mode 100644 index 0000000..c0744b2 --- /dev/null +++ b/apps/web/app/components/editor/withdraw.tsx @@ -0,0 +1,221 @@ +import { authorPath, buildSpecDeletion, toCoordinate, withdrawSpec } from "@openspecs/nostr"; +import { useEffect, useState, useSyncExternalStore } from "react"; +import { Link } from "react-router"; +import { RelayReport } from "~/components/relay-results"; +import { forgetSpec } from "~/lib/corpus"; +import { type RelayResult, signAndPublish } from "~/lib/publish"; +import { documentRelays } from "~/lib/relays"; +import { restoreSession, serverSessionState, sessionState, subscribeSession } from "~/lib/session"; +import { NOTE, SUGGESTION, WRONG } from "./fields"; + +type Stage = "closed" | "asking" | "emptying" | "retracting" | "done" | "failed"; + +const TRIGGER = + "rounded-sm border border-rule px-2 py-1 text-muted hover:border-muted hover:text-ink disabled:hover:border-rule disabled:hover:text-muted"; + +/** + * Hung off the button rather than opened under it, the way a zap and a sign in + * are on this site. The row this sits in is above the document, and a panel that + * pushed it down a screenful would move what somebody is reading every time they + * wondered what this button does. + */ +const PANEL = + "absolute left-0 top-full z-10 mt-1 w-[min(22rem,calc(100vw-3rem))] space-y-3 rounded-sm border border-rule bg-paper p-3 normal-case tracking-normal shadow-sm"; + +const LINK = "underline decoration-rule underline-offset-2 hover:decoration-current"; + +const said = (reason: unknown): string => + reason instanceof Error && reason.message.trim() !== "" ? reason.message : "that did not work"; + +/** + * Withdrawing a document, which takes two events and cannot take one. + * + * A kind 30817 is addressable, so the first is an empty revision that replaces + * it at the same address: that is the only half a relay ignoring NIP-09 will + * honour, and what such a relay goes on serving is then a blank record rather + * than the document. The second is the NIP-09 request to forget the address, + * which a relay that does honour it answers by dropping every revision there, + * the empty one included, leaving nothing and a page that 404s. + * + * The two are reported separately, because the middle outcome is a real one: a + * document emptied everywhere and forgotten nowhere is blank rather than gone, + * and its author should be told that in those words. + * + * Offered only to the key that signed the document, so it renders nothing on the + * server. Nothing has to be read first: an `a` tag names a coordinate, and the + * empty revision needs only a `d`, both of which are in the address of this page. + */ +export const Withdraw = ({ pubkey, identifier }: { pubkey: string; identifier: string }) => { + const session = useSyncExternalStore(subscribeSession, sessionState, serverSessionState); + useEffect(restoreSession, []); + + const [stage, setStage] = useState("closed"); + const [relays, setRelays] = useState([]); + const [emptied, setEmptied] = useState([]); + const [forgotten, setForgotten] = useState([]); + const [error, setError] = useState(null); + /** The empty revision landed somewhere, so the document is blank from then on. */ + const [replaced, setReplaced] = useState(false); + const [refused, setRefused] = useState(false); + + const me = session.pubkey; + if (me === null || me !== pubkey) return null; + + const withdraw = async () => { + setStage("emptying"); + setEmptied([]); + setForgotten([]); + setError(null); + setReplaced(false); + setRefused(false); + + // Resolved once and handed to both, so the request to forget a document + // reaches every relay the empty revision was sent to. + const targets = documentRelays(me); + targets.then(setRelays).catch(() => {}); + + let empty: Awaited>; + try { + empty = await signAndPublish(withdrawSpec(identifier), targets, (result) => + setEmptied((answered) => [...answered, result]), + ); + } catch (reason) { + setStage("failed"); + setError(said(reason)); + return; + } + + // Nothing was replaced anywhere, so there is nothing for a deletion request + // to finish, and a second signer prompt would be asking for it. + if (empty.accepted === 0) { + setStage("failed"); + setError("No relay took the empty revision, so nothing was withdrawn."); + return; + } + + setReplaced(true); + // Dropped from the search here rather than after the request below: what + // makes this stop being a document is the blank revision, not the asking. + void forgetSpec(pubkey, identifier); + setStage("retracting"); + + try { + const forget = await signAndPublish( + { + ...buildSpecDeletion(toCoordinate({ pubkey, identifier })), + /* + * Strictly after the revision it withdraws, rather than whenever this + * line runs. NIP-09 has a relay delete every version of an addressable + * event "up to" this moment and does not say whether that includes it, + * so a relay reading it as `<` would leave behind the very revision + * this was sent to remove. Both events are stamped in whole seconds and + * both can be signed inside one of them, since a local key signs + * without prompting anybody. + */ + created_at: Math.max(Math.floor(Date.now() / 1000), empty.event.created_at + 1), + }, + targets, + (result) => setForgotten((answered) => [...answered, result]), + ); + setRefused(forget.accepted === 0); + setStage("done"); + } catch (reason) { + setStage("failed"); + setError( + `The empty revision went out, but the request to forget it did not: ${said(reason)}`, + ); + } + }; + + const sending = stage === "emptying" || stage === "retracting"; + + return ( +
+ + + {stage !== "closed" && ( +
+ {/* What goes out, before it goes out. Withdrawing is one click of the + two it takes here, and these sentences are the other one. */} + {stage === "asking" ? ( + <> +

+ Two events go out: an empty revision that replaces this one at the same address, + then a request that the relays forget both. Your signer will ask twice. +

+

+ A relay is free to keep serving what it already holds, and anyone who kept a copy of + the event can publish it again. +

+
+ + +
+ + ) : ( + <> + {error !== null &&

{error}

} + + {stage === "done" && ( +

+ {refused + ? "Emptied, but no relay accepted the request to forget it, so the document is blank rather than gone." + : "Withdrawn. This page goes on showing the copy it was served until the cache in front of it expires."}{" "} + + Everything else you signed + + . +

+ )} + + {/* One square per relay for each event, because a withdrawal half + taken is a different outcome from one refused outright, and + only these say which. */} + {relays.length > 0 && ( +
+
+

Emptied

+ +
+ {replaced && ( +
+

Forgotten

+ +
+ )} +
+ )} + + {!sending && ( + + )} + + )} +
+ )} +
+ ); +}; diff --git a/apps/web/app/lib/corpus-store.ts b/apps/web/app/lib/corpus-store.ts index 7c4ffe3..fed736e 100644 --- a/apps/web/app/lib/corpus-store.ts +++ b/apps/web/app/lib/corpus-store.ts @@ -5,10 +5,11 @@ import type { SearchDoc } from "./search"; * Where the corpus waits between visits, so a reader who has already searched * once does not download every document again to search a second time. * - * A document withdrawn in this browser is dropped from it, and nothing else is: - * no kind 5 is read here, so a document somebody else retracted stays searchable - * until a walk stops finding it. Relays are the source of truth, and this is a - * cache that admits it. + * A document withdrawn in this browser is dropped from it, and nothing else is. + * A walk only ever adds and replaces, and the empty revision that would say a + * document was withdrawn is filtered out before it gets here, so one somebody + * else retracted stays searchable until this store is dropped. Relays are the + * source of truth, and this is a cache that admits it. */ const DB = "openspecs"; const VERSION = 1; diff --git a/apps/web/app/routes/spec.tsx b/apps/web/app/routes/spec.tsx index cd958bf..ffa2c54 100644 --- a/apps/web/app/routes/spec.tsx +++ b/apps/web/app/routes/spec.tsx @@ -12,6 +12,7 @@ import { AuthorAvatar } from "~/components/author-avatar"; import { CopyButton } from "~/components/copy-button"; import { DISCUSSION_ID, Discussion } from "~/components/discussion/discussion"; import { EditLink } from "~/components/editor/edit-link"; +import { Withdraw } from "~/components/editor/withdraw"; import { ErrorPage } from "~/components/error-page"; import { Rebroadcast } from "~/components/rebroadcast"; import { Shell } from "~/components/shell"; @@ -277,6 +278,7 @@ const Masthead = ({ /> + ); @@ -315,19 +317,28 @@ const CitedLinks = ({ previews }: { previews: LinkPreview[] }) => ( /** * Offered above the document rather than swapped into it, and drawn dashed like * everything on this site that is available rather than settled. + * + * A revision with nothing in it is an author withdrawing their document, so it + * is said in those words: "a newer revision" over a blank record reads as an + * edit somebody would want to see. Nothing is offered alongside it either, since + * what there is to show is an empty page, and the sentence already is that. */ -const Fresher = ({ revisedAt, onShow }: { revisedAt: number; onShow: () => void }) => ( +const Fresher = ({ fresher, onShow }: { fresher: SpecPage; onShow: () => void }) => (

- Its author published a newer revision on {asDate(revisedAt)}. + {fresher.isEmpty + ? `Its author withdrew this document on ${asDate(fresher.revisedAt)}. What is below is the copy this page was served.` + : `Its author published a newer revision on ${asDate(fresher.revisedAt)}.`}

- + {!fresher.isEmpty && ( + + )}
); @@ -354,7 +365,7 @@ const Article = ({ return (
- {fresher !== null && } + {fresher !== null && } From dd8414709c0e9a017bf9029cbb8692a561c78d69 Mon Sep 17 00:00:00 2001 From: Nogringo Date: Sat, 22 Aug 2026 21:28:59 +0200 Subject: [PATCH 3/3] feat: keep a withdrawn document out of every reader's search --- apps/web/app/components/editor/withdraw.tsx | 9 ++-- apps/web/app/lib/corpus-store.test.ts | 50 +++++++++++++++------ apps/web/app/lib/corpus-store.ts | 21 +++++---- apps/web/app/lib/corpus.ts | 45 +++++++++---------- 4 files changed, 75 insertions(+), 50 deletions(-) diff --git a/apps/web/app/components/editor/withdraw.tsx b/apps/web/app/components/editor/withdraw.tsx index c0744b2..aef1252 100644 --- a/apps/web/app/components/editor/withdraw.tsx +++ b/apps/web/app/components/editor/withdraw.tsx @@ -2,7 +2,7 @@ import { authorPath, buildSpecDeletion, toCoordinate, withdrawSpec } from "@open import { useEffect, useState, useSyncExternalStore } from "react"; import { Link } from "react-router"; import { RelayReport } from "~/components/relay-results"; -import { forgetSpec } from "~/lib/corpus"; +import { rememberSpec } from "~/lib/corpus"; import { type RelayResult, signAndPublish } from "~/lib/publish"; import { documentRelays } from "~/lib/relays"; import { restoreSession, serverSessionState, sessionState, subscribeSession } from "~/lib/session"; @@ -94,9 +94,10 @@ export const Withdraw = ({ pubkey, identifier }: { pubkey: string; identifier: s } setReplaced(true); - // Dropped from the search here rather than after the request below: what - // makes this stop being a document is the blank revision, not the asking. - void forgetSpec(pubkey, identifier); + // The blank revision goes into the search the way a written one does, and + // drops out of it by being blank. Here rather than after the request below: + // what stops this being a document is the revision, not the asking. + void rememberSpec(empty.event); setStage("retracting"); try { diff --git a/apps/web/app/lib/corpus-store.test.ts b/apps/web/app/lib/corpus-store.test.ts index 7aad3d4..bc1f6bc 100644 --- a/apps/web/app/lib/corpus-store.test.ts +++ b/apps/web/app/lib/corpus-store.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { mergeDocs, withoutDoc } from "./corpus-store"; +import { mergeDocs, written } from "./corpus-store"; import type { SearchDoc } from "./search"; const doc = (identifier: string, revisedAt: number, title = identifier): SearchDoc => ({ @@ -14,6 +14,13 @@ const doc = (identifier: string, revisedAt: number, title = identifier): SearchD topics: [], publishedAt: 1_700_000_000, revisedAt, + content: `# ${title}`, +}); + +/** What an author replaced a document with to withdraw it: an address and no text. */ +const blank = (identifier: string, revisedAt: number): SearchDoc => ({ + ...doc(identifier, revisedAt, identifier), + title: identifier, content: "", }); @@ -46,25 +53,42 @@ describe("mergeDocs", () => { }); }); -describe("withoutDoc", () => { - const author = "a".repeat(64); +describe("mergeDocs and a withdrawal", () => { + it("lets a blank revision supersede the document it replaced", () => { + expect(written(mergeDocs([doc("a", 1)], [blank("a", 2)]))).toEqual([]); + }); + + it("holds the blank revision, so it is there to outrank an older copy later", () => { + const merged = mergeDocs([doc("a", 1)], [blank("a", 2)]); + expect(merged).toHaveLength(1); + expect(merged[0]?.content).toBe(""); + }); + + /* Pages arrive from several relays in no order. One serving the revision a + withdrawal replaced must not undo the withdrawal by answering last. */ + it("keeps a document withdrawn when an older copy arrives afterwards", () => { + const withdrawn = mergeDocs([doc("a", 1)], [blank("a", 2)]); + expect(written(mergeDocs(withdrawn, [doc("a", 1)]))).toEqual([]); + }); - it("drops the document at that coordinate and leaves the rest", () => { - expect(titles(withoutDoc([doc("a", 1), doc("b", 1)], author, "a"))).toEqual(["b"]); + it("lets an author publish again at an address they withdrew", () => { + const withdrawn = mergeDocs([doc("a", 1)], [blank("a", 2)]); + expect(titles(written(mergeDocs(withdrawn, [doc("a", 3, "again")])))).toEqual(["again"]); }); - it("leaves another author's document of the same identifier alone", () => { + it("withdraws one document without touching another author's at the same address", () => { const theirs = { ...doc("shared", 1, "theirs"), pubkey: "b".repeat(64) }; - expect(titles(withoutDoc([doc("shared", 1, "mine"), theirs], author, "shared"))).toEqual([ - "theirs", - ]); + const merged = mergeDocs([doc("shared", 1, "mine"), theirs], [blank("shared", 2)]); + expect(titles(written(merged))).toEqual(["theirs"]); }); +}); - it("drops every revision held for it, not the newest one", () => { - expect(withoutDoc([doc("a", 1), doc("a", 2)], author, "a")).toEqual([]); +describe("written", () => { + it("keeps the documents there is something to read", () => { + expect(titles(written([doc("a", 1), blank("b", 1), doc("c", 1)]))).toEqual(["a", "c"]); }); - it("is nothing to do when the document was never stored", () => { - expect(titles(withoutDoc([doc("a", 1)], author, "gone"))).toEqual(["a"]); + it("counts a document of nothing but whitespace as blank", () => { + expect(written([{ ...doc("a", 1), content: " \n\t " }])).toEqual([]); }); }); diff --git a/apps/web/app/lib/corpus-store.ts b/apps/web/app/lib/corpus-store.ts index fed736e..2dcf353 100644 --- a/apps/web/app/lib/corpus-store.ts +++ b/apps/web/app/lib/corpus-store.ts @@ -5,11 +5,12 @@ import type { SearchDoc } from "./search"; * Where the corpus waits between visits, so a reader who has already searched * once does not download every document again to search a second time. * - * A document withdrawn in this browser is dropped from it, and nothing else is. - * A walk only ever adds and replaces, and the empty revision that would say a - * document was withdrawn is filtered out before it gets here, so one somebody - * else retracted stays searchable until this store is dropped. Relays are the - * source of truth, and this is a cache that admits it. + * A withdrawn document is kept here, blank, rather than deleted. Its author + * replaced it with an empty revision, and that revision is a document's newest + * one like any other: holding it is what makes `mergeDocs` refuse the written + * revision it superseded, whichever relay serves that one and whenever it + * arrives. Deleting the row instead would let the next walk that met an old copy + * put the document back. `written` below is what keeps the blanks off screen. */ const DB = "openspecs"; const VERSION = 1; @@ -36,9 +37,13 @@ export const mergeDocs = (existing: SearchDoc[], incoming: SearchDoc[]): SearchD return [...live.values()]; }; -/** The one document at a coordinate, gone. Its author withdrew it. */ -export const withoutDoc = (docs: SearchDoc[], pubkey: string, identifier: string): SearchDoc[] => - docs.filter((doc) => coordinateOf(doc) !== `${pubkey}:${identifier}`); +/** + * The documents there is something to read, which is what a search is of. A + * blank one is an address its author withdrew, and it is carried everywhere + * except in front of somebody. + */ +export const written = (docs: SearchDoc[]): SearchDoc[] => + docs.filter((doc) => doc.content.trim() !== ""); const open = (): Promise => new Promise((resolve, reject) => { diff --git a/apps/web/app/lib/corpus.ts b/apps/web/app/lib/corpus.ts index 967a74b..50f6145 100644 --- a/apps/web/app/lib/corpus.ts +++ b/apps/web/app/lib/corpus.ts @@ -1,10 +1,11 @@ import type { NostrEvent, Spec } from "@openspecs/nostr"; -import { mergeDocs, readStored, withoutDoc, writeStored } from "./corpus-store"; +import { mergeDocs, readStored, writeStored, written } from "./corpus-store"; import type { SearchDoc } from "./search"; export type CorpusStatus = "idle" | "loading" | "syncing" | "ready" | "failed"; export type CorpusState = { + /** Only the documents with something in them: a withdrawn one is held, never shown. */ docs: SearchDoc[]; status: CorpusStatus; /** Documents read from the relays during this run, so a long walk can be watched. */ @@ -17,6 +18,7 @@ export const EMPTY_CORPUS: CorpusState = { docs: [], status: "idle", read: 0 }; const NOTIFY_MS = 150; let state = EMPTY_CORPUS; +/** Every revision the walk resolved, blank ones included. `state` is what is shown. */ let docs: SearchDoc[] = []; let read = 0; let started = false; @@ -31,7 +33,7 @@ const publish = (status: CorpusStatus): void => { clearTimeout(timer); timer = null; } - state = { docs, status, read }; + state = { docs: written(docs), status, read }; for (const listener of listeners) listener(); }; @@ -89,19 +91,23 @@ const walk = async (): Promise => { // nostr-tools comes with the search, not with the page: someone who only // reads should not download a relay client to do it. const { specPath, syncSpecs, toNpub } = await import("@openspecs/nostr"); - const usable = (page: Spec[]): SearchDoc[] => - page.filter((spec) => !spec.isEmpty).map((spec) => toDoc(spec, specPath, toNpub)); + // Blank revisions come through with the rest, rather than being dropped + // here: one is how an author says a document is withdrawn, and `mergeDocs` + // is where the newest revision of a coordinate wins whatever it says. Pages + // arrive from several relays in no order, so a walk that filtered them out + // would settle a withdrawn document on whichever relay answered last. + const asDocs = (page: Spec[]): SearchDoc[] => page.map((spec) => toDoc(spec, specPath, toNpub)); const { specs, cursors } = await syncSpecs({ cursors: stored.cursors, onPage: (page) => { - docs = mergeDocs(docs, usable(page)); + docs = mergeDocs(docs, asDocs(page)); read += page.length; publishSoon(); }, }); - docs = mergeDocs(docs, usable(specs)); + docs = mergeDocs(docs, asDocs(specs)); publish("ready"); // A relay that answered nothing keeps the cursor it had, rather than losing // its place because it was unreachable once. @@ -121,17 +127,23 @@ export const startCorpus = (): void => { }; /** - * A document its author just published. The walk above happens once a session + * A revision its author just published. The walk above happens once a session * and nothing tells it a document appeared, so without this the one thing * somebody wrote here is the one thing they cannot find here. * + * A blank revision is one of these too, and is why there is no `forgetSpec` + * beside this: withdrawing a document is publishing an empty one over it, so it + * arrives the same way, outranks what it replaces by being newer, and drops out + * of the search because there is nothing in it rather than because something + * here went looking for it. + * * Written to disk as well as held, since a walk that has not started yet reads * from disk and would replace what is in memory with what is on it. */ export const rememberSpec = async (event: NostrEvent): Promise => { const { parseSpec, specPath, toNpub } = await import("@openspecs/nostr"); const spec = parseSpec(event); - if (spec === null || spec.isEmpty) return; + if (spec === null) return; const doc = toDoc(spec, specPath, toNpub); docs = mergeDocs(docs, [doc]); @@ -140,20 +152,3 @@ export const rememberSpec = async (event: NostrEvent): Promise => { const stored = await readStored(); await writeStored(mergeDocs(stored.docs, [doc]), stored.cursors); }; - -/** - * A document its author just withdrew. Written back the same way, and for the - * same reason: a walk that has not started yet reads from disk and would put - * back what this took out of memory. - * - * A later walk may find it again on a relay that kept serving it, and it should: - * this searches what the relays hold, and correcting them here would be an index - * that disagrees with every page it links to. - */ -export const forgetSpec = async (pubkey: string, identifier: string): Promise => { - docs = withoutDoc(docs, pubkey, identifier); - if (started) publish(state.status); - - const stored = await readStored(); - await writeStored(withoutDoc(stored.docs, pubkey, identifier), stored.cursors); -};