Merge pull request #707 from PR0M3TH3AN/codex/implement-batch-delete-event-mechanism

Add structured relay delete handling for video removals
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-20 11:23:50 -04:00
commit b0f721e838
3 files changed
+439 -14

No files matched your search

+176 -5
View File
@@ -38,6 +38,7 @@ import {
publishEventToRelay,
publishEventToRelays,
assertAnyRelayAccepted,
summarizePublishResults,
} from "./nostrPublish.js";
import { nostrToolsReady } from "./nostrToolsBootstrap.js";
import { devLogger, userLogger } from "./utils/logger.js";
@@ -7131,7 +7132,11 @@ export class NostrClient {
});
}
return signedEvent;
return {
event: signedEvent,
publishResults,
summary: publishSummary,
};
}
/**
@@ -7226,6 +7231,9 @@ export class NostrClient {
throw new Error("No existing events found for that root.");
}
const revertSummaries = [];
const revertEvents = [];
for (const vid of matchingEvents.values()) {
const baseRoot =
(typeof vid.videoRootId === "string" && vid.videoRootId) ||
@@ -7251,7 +7259,7 @@ export class NostrClient {
videoRootId: baseRoot,
};
const signedRevert = await this.revertVideo(
const revertResult = await this.revertVideo(
{
id: vid.id,
pubkey: vid.pubkey,
@@ -7261,6 +7269,26 @@ export class NostrClient {
pubkey
);
const revertEvent = revertResult?.event || null;
const revertSummary =
revertResult?.summary ||
summarizePublishResults(revertResult?.publishResults || []);
const revertPublishResults = Array.isArray(revertResult?.publishResults)
? revertResult.publishResults
: [];
revertSummaries.push({
targetId: vid.id || "",
event: revertEvent,
publishResults: revertPublishResults,
summary: revertSummary,
});
if (revertEvent?.id) {
revertEvents.push(revertEvent);
this.rawEvents.set(revertEvent.id, revertEvent);
}
const cached = this.allEvents.get(vid.id) || vid;
cached.deleted = true;
cached.url = "";
@@ -7273,16 +7301,159 @@ export class NostrClient {
const activeKey = getActiveKey(cached);
if (activeKey) {
this.activeMap.delete(activeKey);
const revertCreatedAt = Number.isFinite(signedRevert?.created_at)
? Math.floor(signedRevert.created_at)
const revertCreatedAt = Number.isFinite(revertEvent?.created_at)
? Math.floor(revertEvent.created_at)
: Math.floor(Date.now() / 1000);
this.recordTombstone(activeKey, revertCreatedAt);
}
}
const eventIdSet = new Set();
const addressPointerSet = new Set();
const collectIdentifiersFromEvent = (eventLike) => {
if (!eventLike || typeof eventLike !== "object") {
return;
}
const eventId = typeof eventLike.id === "string" ? eventLike.id : "";
if (eventId) {
eventIdSet.add(eventId);
}
const pointerSources = [];
if (eventLike.kind && Array.isArray(eventLike.tags)) {
pointerSources.push(eventLike);
}
if (eventId) {
const raw = this.rawEvents.get(eventId);
if (raw && raw !== eventLike) {
pointerSources.push(raw);
}
}
for (const source of pointerSources) {
const pointer = eventToAddressPointer(source);
if (pointer) {
addressPointerSet.add(pointer);
}
}
};
matchingEvents.forEach((event) => collectIdentifiersFromEvent(event));
revertEvents.forEach((event) => collectIdentifiersFromEvent(event));
if (targetVideo) {
collectIdentifiersFromEvent(targetVideo);
}
const identifierRecords = [];
eventIdSet.forEach((value) => {
identifierRecords.push({ type: "e", value });
});
addressPointerSet.forEach((value) => {
identifierRecords.push({ type: "a", value });
});
const deleteSummaries = [];
if (identifierRecords.length) {
const extension = window?.nostr;
const extensionSigner =
extension && typeof extension.signEvent === "function"
? extension.signEvent.bind(extension)
: null;
if (typeof extensionSigner !== "function") {
const error = new Error(
"A NIP-07 extension with signEvent support is required to delete videos.",
);
error.code = "nostr-extension-missing";
throw error;
}
const permissionResult = await this.ensureExtensionPermissions(
DEFAULT_NIP07_PERMISSION_METHODS,
);
if (!permissionResult.ok) {
userLogger.warn(
"[nostr] Extension permissions denied while deleting videos.",
permissionResult.error,
);
const error = new Error(
"The NIP-07 extension must grant decrypt and sign permissions before deleting a video.",
);
error.code = "extension-permission-denied";
error.cause = permissionResult.error;
throw error;
}
const chunkSize = 100;
for (let index = 0; index < identifierRecords.length; index += chunkSize) {
const chunk = identifierRecords.slice(index, index + chunkSize);
const deleteTags = chunk.map((record) => [record.type, record.value]);
const deleteEvent = {
kind: 5,
pubkey,
created_at: Math.floor(Date.now() / 1000),
tags: deleteTags,
content: inferredRoot
? `Delete video root ${inferredRoot}`
: "Delete published video events",
};
const signedDelete = await extensionSigner(deleteEvent);
const publishResults = await publishEventToRelays(
this.pool,
this.relays,
signedDelete,
);
const publishSummary = summarizePublishResults(publishResults);
publishSummary.accepted.forEach(({ url }) =>
devLogger.log(`Delete event published to ${url}`),
);
if (publishSummary.failed.length) {
publishSummary.failed.forEach(({ url, error: relayError }) => {
const reason =
relayError instanceof Error
? relayError.message
: relayError
? String(relayError)
: "publish failed";
userLogger.warn(
`[nostr] Delete event not accepted by ${url}: ${reason}`,
relayError,
);
});
}
if (signedDelete?.id) {
this.rawEvents.set(signedDelete.id, signedDelete);
}
deleteSummaries.push({
event: signedDelete,
publishResults,
summary: publishSummary,
identifiers: {
events: chunk
.filter((record) => record.type === "e")
.map((record) => record.value),
addresses: chunk
.filter((record) => record.type === "a")
.map((record) => record.value),
},
});
}
}
this.saveLocalData();
return true;
return {
reverts: revertSummaries,
deletes: deleteSummaries,
};
}
/**
+57 -3
View File
@@ -644,10 +644,64 @@ export class NostrService {
confirm,
video,
});
if (result) {
this.emit("videos:deleted", { videoRootId, video, pubkey });
if (!result) {
return result;
}
return result;
const revertFailures = [];
const deleteFailures = [];
if (Array.isArray(result.reverts)) {
for (const entry of result.reverts) {
if (!entry || typeof entry !== "object") {
continue;
}
const failed = Array.isArray(entry?.summary?.failed)
? entry.summary.failed.filter(Boolean)
: [];
if (failed.length) {
revertFailures.push({
targetId: entry.targetId || "",
eventId: entry.event?.id || "",
failed,
});
}
}
}
if (Array.isArray(result.deletes)) {
for (const entry of result.deletes) {
if (!entry || typeof entry !== "object") {
continue;
}
const failed = Array.isArray(entry?.summary?.failed)
? entry.summary.failed.filter(Boolean)
: [];
if (failed.length) {
deleteFailures.push({
eventId: entry.event?.id || "",
identifiers: entry.identifiers || { events: [], addresses: [] },
failed,
});
}
}
}
const detail = {
videoRootId,
video,
pubkey,
result,
revertFailures,
deleteFailures,
};
this.emit("videos:deleted", detail);
return detail;
}
async getOldEventById(eventId) {
+206 -6
View File
@@ -1,6 +1,7 @@
import "./test-helpers/setup-localstorage.mjs";
import assert from "node:assert/strict";
import { NostrClient } from "../js/nostr.js";
import { NostrService } from "../js/services/nostrService.js";
await (async function testDeleteFlowPublishesDeletionFlag() {
localStorage.clear();
@@ -29,14 +30,67 @@ await (async function testDeleteFlowPublishesDeletionFlag() {
client.allEvents.set(baseVideo.id, { ...baseVideo });
const signedEvents = [];
window.nostr = {
signEvent: async (event) => {
signedEvents.push(event);
return { ...event, id: `signed-${signedEvents.length}` };
},
};
client.ensureExtensionPermissions = async () => ({ ok: true });
client.relays = ["wss://relay.ok", "wss://relay.fail"];
client.rawEvents.set(baseVideo.id, {
id: baseVideo.id,
kind: 30078,
pubkey,
tags: baseVideo.tags,
});
client.pool = {
publish([url], event) {
const shouldFail = event.kind === 5 && url.includes("fail");
return {
on(type, handler) {
if (type === "ok") {
if (!shouldFail) {
handler();
}
return true;
}
if (type === "failed") {
if (shouldFail) {
handler(new Error(`reject-${url}`));
}
return true;
}
return false;
},
};
},
};
const revertCalls = [];
const revertEvents = [];
client.revertVideo = async (event) => {
revertCalls.push(event);
const created_at = baseVideo.created_at + 500 + revertCalls.length;
const revertEvent = { id: `revert-${revertCalls.length}`, created_at };
const revertEvent = {
id: `revert-${revertCalls.length}`,
kind: 30078,
pubkey,
created_at,
tags: baseVideo.tags,
};
revertEvents.push(revertEvent);
return revertEvent;
return {
event: revertEvent,
publishResults: client.relays.map((url) => ({ url, success: true })),
summary: {
accepted: client.relays.map((url) => ({ url, success: true })),
failed: [],
},
};
};
const result = await client.deleteAllVersions(videoRootId, pubkey, {
@@ -44,9 +98,66 @@ await (async function testDeleteFlowPublishesDeletionFlag() {
video: baseVideo,
});
assert.equal(result, true, "deleteAllVersions should resolve true on success");
assert.equal(
Array.isArray(result.deletes),
true,
"deleteAllVersions should return delete summaries",
);
assert.equal(revertCalls.length, 1, "deleteAllVersions should call revertVideo once");
const deleteSummary = result.deletes[0];
assert.equal(
deleteSummary.summary.failed.length,
1,
"deleteAllVersions should surface relay failures for delete events",
);
assert.equal(
deleteSummary.summary.failed[0].url,
"wss://relay.fail",
"delete summary should identify the failing relay",
);
assert.equal(
deleteSummary.identifiers.events.includes(baseVideo.id),
true,
"delete summary should include the original event id",
);
assert.equal(
deleteSummary.identifiers.events.includes(revertEvents[0].id),
true,
"delete summary should include the revert event id",
);
assert.equal(
deleteSummary.identifiers.addresses.includes(
`30078:${pubkey}:${videoRootId}`,
),
true,
"delete summary should include the address pointer for the root",
);
const deleteRequest = signedEvents.find((event) => event.kind === 5);
assert(deleteRequest, "deleteAllVersions should sign a kind 5 delete event");
const eTags = deleteRequest.tags
.filter((tag) => Array.isArray(tag) && tag[0] === "e")
.map(([, value]) => value);
assert.equal(
eTags.includes(baseVideo.id),
true,
"delete event should include original event id as an e-tag",
);
assert.equal(
eTags.includes(revertEvents[0].id),
true,
"delete event should include revert event id as an e-tag",
);
const aTags = deleteRequest.tags
.filter((tag) => Array.isArray(tag) && tag[0] === "a")
.map(([, value]) => value);
assert.equal(
aTags.includes(`30078:${pubkey}:${videoRootId}`),
true,
"delete event should include the address pointer as an a-tag",
);
const parsedPayload = JSON.parse(revertCalls[0].content);
assert.equal(parsedPayload.deleted, true, "revert payload should mark deleted true");
assert.equal(parsedPayload.videoRootId, videoRootId);
@@ -89,7 +200,7 @@ await (async function testDeleteFlowPublishesDeletionFlag() {
const deleteEvent = {
id: "delete-event-1",
pubkey,
created_at: baseVideo.created_at + 100,
created_at: revertEvents[0].created_at + 100,
content: revertCalls[0].content,
tags: baseVideo.tags,
};
@@ -117,6 +228,7 @@ await (async function testDeleteFlowPublishesDeletionFlag() {
deleteEvent.created_at,
"subscription delete event should update tombstone",
);
delete window.nostr;
})();
await (async function testTombstonePersistenceAcrossSaveRestore() {
@@ -203,8 +315,8 @@ await (async function testSubscribeVideosSkipsOlderThanTombstone() {
content: JSON.stringify({
version: 3,
deleted: true,
title: "",
url: "",
title: "Deleted",
url: "https://example.com/video.mp4",
magnet: "",
thumbnail: "",
description: "",
@@ -269,4 +381,92 @@ await (async function testSubscribeVideosSkipsOlderThanTombstone() {
);
})();
await (async function testNostrServiceBubblesDeleteFailures() {
const service = new NostrService();
const pubkey = "PUBKEY123";
const videoRootId = "root-service";
const sampleVideo = { id: "evt-service", pubkey, videoRootId };
const revertFailure = {
url: "wss://relay.fail",
error: new Error("revert failed"),
success: false,
};
const deleteFailure = {
url: "wss://relay.fail",
error: new Error("delete failed"),
success: false,
};
const summary = {
reverts: [
{
targetId: sampleVideo.id,
event: { id: "revert-event" },
publishResults: [
{ url: "wss://relay.ok", success: true },
revertFailure,
],
summary: {
accepted: [{ url: "wss://relay.ok", success: true }],
failed: [revertFailure],
},
},
],
deletes: [
{
event: { id: "delete-event" },
identifiers: {
events: [sampleVideo.id],
addresses: ["30078:pubkey:root-service"],
},
publishResults: [
{ url: "wss://relay.ok", success: true },
deleteFailure,
],
summary: {
accepted: [{ url: "wss://relay.ok", success: true }],
failed: [deleteFailure],
},
},
],
};
service.nostrClient = {
deleteAllVersions: async () => summary,
};
const emitted = [];
service.on("videos:deleted", (detail) => emitted.push(detail));
const detail = await service.handleFullDeleteVideo({
videoRootId,
video: sampleVideo,
pubkey,
confirm: false,
});
assert.equal(emitted.length, 1, "service should emit videos:deleted event");
assert.equal(
emitted[0].result,
summary,
"emitted detail should include the delete summary",
);
assert.equal(
detail.deleteFailures.length,
1,
"service should surface delete relay failures",
);
assert.equal(
detail.revertFailures.length,
1,
"service should surface revert relay failures",
);
assert.equal(
detail.deleteFailures[0].failed[0].url,
"wss://relay.fail",
"delete failures should include the failing relay url",
);
})();
console.log("nostr delete flow tests passed");