Merge pull request #704 from PR0M3TH3AN/codex/implement-tombstone-management-for-nostrclient

Add tombstone guards to nostr video cache
This commit is contained in:
thePR0M3TH3AN authored and GitHub committed 2025-10-20 10:52:32 -04:00
commit 4ac5a776a6
6 files changed
+432 -16

No files matched your search

+173 -5
View File
@@ -3156,6 +3156,9 @@ export class NostrClient {
// “activeMap” holds only the newest version for each root
this.activeMap = new Map();
// Track the newest deletion timestamp for each active key
this.tombstones = new Map();
this.rootCreatedAtByRoot = new Map();
this.hasRestoredLocalData = false;
@@ -3176,6 +3179,93 @@ export class NostrClient {
storedPermissions instanceof Set ? storedPermissions : new Set();
}
recordTombstone(activeKey, createdAt) {
const key = typeof activeKey === "string" ? activeKey.trim() : "";
if (!key) {
return;
}
let timestamp = Number(createdAt);
if (!Number.isFinite(timestamp)) {
return;
}
timestamp = Math.max(0, Math.floor(timestamp));
if (timestamp <= 0) {
return;
}
const previous = this.tombstones.get(key);
const previousTimestamp = Number.isFinite(previous)
? Math.max(0, Math.floor(previous))
: 0;
if (previousTimestamp >= timestamp) {
return;
}
this.tombstones.set(key, timestamp);
const activeVideo = this.activeMap.get(key);
if (activeVideo) {
const activeCreated = Number.isFinite(activeVideo?.created_at)
? Math.floor(activeVideo.created_at)
: 0;
if (activeCreated && activeCreated <= timestamp) {
activeVideo.deleted = true;
this.activeMap.delete(key);
}
}
for (const video of this.allEvents.values()) {
if (!video || typeof video !== "object") {
continue;
}
if (getActiveKey(video) !== key) {
continue;
}
const createdAtValue = Number.isFinite(video?.created_at)
? Math.floor(video.created_at)
: 0;
if (createdAtValue && createdAtValue <= timestamp) {
video.deleted = true;
}
}
}
isOlderThanTombstone(video) {
if (!video || typeof video !== "object") {
return false;
}
const activeKey = getActiveKey(video);
if (!activeKey) {
return false;
}
const tombstone = this.tombstones.get(activeKey);
if (!Number.isFinite(tombstone)) {
return false;
}
const normalizedTombstone = Math.max(0, Math.floor(tombstone));
const createdAtValue = Number.isFinite(video?.created_at)
? Math.floor(video.created_at)
: 0;
return createdAtValue > 0 && createdAtValue <= normalizedTombstone;
}
applyTombstoneGuard(video) {
if (!video || typeof video !== "object") {
return false;
}
const isGuarded = this.isOlderThanTombstone(video);
if (isGuarded) {
video.deleted = true;
}
return isGuarded;
}
markExtensionPermissions(methods = []) {
if (!this.extensionPermissionCache) {
this.extensionPermissionCache = new Set();
@@ -3659,6 +3749,21 @@ export class NostrClient {
this.rawEvents.clear();
this.activeMap.clear();
this.rootCreatedAtByRoot.clear();
this.tombstones.clear();
if (Array.isArray(payload.tombstones)) {
for (const entry of payload.tombstones) {
if (!Array.isArray(entry) || entry.length < 2) {
continue;
}
const [key, value] = entry;
const normalizedKey = typeof key === "string" ? key.trim() : "";
const timestamp = Number.isFinite(value) ? Math.floor(value) : 0;
if (normalizedKey && timestamp > 0) {
this.tombstones.set(normalizedKey, timestamp);
}
}
}
for (const [id, video] of Object.entries(events)) {
if (!id || !video || typeof video !== "object") {
@@ -3666,12 +3771,19 @@ export class NostrClient {
}
this.applyRootCreatedAt(video);
const activeKey = getActiveKey(video);
if (video.deleted) {
this.recordTombstone(activeKey, video.created_at);
} else {
this.applyTombstoneGuard(video);
}
this.allEvents.set(id, video);
if (video.deleted) {
continue;
}
const activeKey = getActiveKey(video);
const existing = this.activeMap.get(activeKey);
if (!existing || video.created_at > existing.created_at) {
this.activeMap.set(activeKey, video);
@@ -7139,7 +7251,7 @@ export class NostrClient {
videoRootId: baseRoot,
};
await this.revertVideo(
const signedRevert = await this.revertVideo(
{
id: vid.id,
pubkey: vid.pubkey,
@@ -7161,9 +7273,15 @@ 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)
: Math.floor(Date.now() / 1000);
this.recordTombstone(activeKey, revertCreatedAt);
}
}
this.saveLocalData();
return true;
}
@@ -7179,6 +7297,7 @@ export class NostrClient {
version: 1,
savedAt: Date.now(),
events: {},
tombstones: Array.from(this.tombstones.entries()),
};
for (const [id, vid] of this.allEvents.entries()) {
@@ -7239,18 +7358,34 @@ export class NostrClient {
this.mergeNip71MetadataIntoVideo(video);
this.applyRootCreatedAt(video);
const activeKey = getActiveKey(video);
const wasDeletedEvent = video.deleted === true;
if (wasDeletedEvent) {
this.recordTombstone(activeKey, video.created_at);
} else {
this.applyTombstoneGuard(video);
}
// Store in allEvents
this.allEvents.set(evt.id, video);
// If it's a "deleted" note, remove from activeMap
if (video.deleted) {
const activeKey = getActiveKey(video);
this.activeMap.delete(activeKey);
if (activeKey) {
if (wasDeletedEvent) {
this.activeMap.delete(activeKey);
} else {
const currentActive = this.activeMap.get(activeKey);
if (currentActive?.id === video.id) {
this.activeMap.delete(activeKey);
}
}
}
continue;
}
// Otherwise, if it's newer than what we have, update activeMap
const activeKey = getActiveKey(video);
const prevActive = this.activeMap.get(activeKey);
if (!prevActive || video.created_at > prevActive.created_at) {
this.activeMap.set(activeKey, video);
@@ -7747,6 +7882,12 @@ export class NostrClient {
} else {
// Only add if good
this.applyRootCreatedAt(vid);
const activeKey = getActiveKey(vid);
if (vid.deleted) {
this.recordTombstone(activeKey, vid.created_at);
} else {
this.applyTombstoneGuard(vid);
}
localAll.set(evt.id, vid);
}
}
@@ -7919,6 +8060,12 @@ export class NostrClient {
if (local) {
this.applyRootCreatedAt(local);
const localKey = getActiveKey(local);
if (local.deleted) {
this.recordTombstone(localKey, local.created_at);
} else {
this.applyTombstoneGuard(local);
}
if (!includeRaw) {
return local;
}
@@ -7939,6 +8086,12 @@ export class NostrClient {
const video = convertEventToVideo(rawEvent);
this.applyRootCreatedAt(video);
const activeKey = getActiveKey(video);
if (video.deleted) {
this.recordTombstone(activeKey, video.created_at);
} else {
this.applyTombstoneGuard(video);
}
this.allEvents.set(eventId, video);
if (includeRaw) {
@@ -8925,6 +9078,9 @@ export class NostrClient {
if (sameRoot || sameD || sameLegacyRoot || candidate.id === video.id) {
if (!seen.has(candidate.id)) {
seen.add(candidate.id);
if (!candidate.deleted) {
this.applyTombstoneGuard(candidate);
}
matches.push(candidate);
}
}
@@ -8971,6 +9127,12 @@ export class NostrClient {
if (!parsed.invalid) {
this.mergeNip71MetadataIntoVideo(parsed);
this.applyRootCreatedAt(parsed);
const activeKey = getActiveKey(parsed);
if (parsed.deleted) {
this.recordTombstone(activeKey, parsed.created_at);
} else {
this.applyTombstoneGuard(parsed);
}
this.allEvents.set(rootEvent.id, parsed);
localMatches.push(parsed);
}
@@ -9025,6 +9187,12 @@ export class NostrClient {
if (!parsed.invalid) {
this.mergeNip71MetadataIntoVideo(parsed);
this.applyRootCreatedAt(parsed);
const activeKey = getActiveKey(parsed);
if (parsed.deleted) {
this.recordTombstone(activeKey, parsed.created_at);
} else {
this.applyTombstoneGuard(parsed);
}
this.allEvents.set(evt.id, parsed);
}
} catch (err) {
+13
View File
@@ -659,6 +659,14 @@ export class NostrService {
if (map.has(eventId)) {
const existing = map.get(eventId);
if (existing && !existing.deleted) {
this.nostrClient.applyTombstoneGuard(existing);
}
if (!existing || existing.deleted) {
map.delete(eventId);
setStoredVideosMap(map);
return null;
}
if (isBlockedNsfw(existing)) {
map.delete(eventId);
setStoredVideosMap(map);
@@ -668,6 +676,11 @@ export class NostrService {
}
const cached = this.nostrClient.allEvents.get(eventId);
if (cached) {
if (!cached.deleted) {
this.nostrClient.applyTombstoneGuard(cached);
}
}
if (cached && !cached.deleted) {
if (isBlockedNsfw(cached)) {
return null;
+2 -1
View File
@@ -1,6 +1,7 @@
const VAR_PATTERN = /^var\(--.*\)$/;
const CONTAINS_VAR_PATTERN = /var\(--.*\)/;
const LINEAR_GRADIENT_PATTERN = /^linear-gradient\(/;
const COLOR_MIX_PATTERN = /^color-mix\([\s\S]+\)$/;
const NON_TOKEN_LENGTH_PATTERN = /(?!.*var\().*\b\d*\.?\d+(?:px|rem)\b/;
module.exports = {
@@ -11,7 +12,7 @@ module.exports = {
'color-no-hex': true,
'declaration-property-value-allowed-list': {
color: [VAR_PATTERN, 'inherit', 'currentColor'],
'background-color': [VAR_PATTERN, 'transparent', 'inherit'],
'background-color': [VAR_PATTERN, 'transparent', 'inherit', 'currentColor', COLOR_MIX_PATTERN],
background: [VAR_PATTERN, CONTAINS_VAR_PATTERN, LINEAR_GRADIENT_PATTERN, 'none', 'transparent'],
'border-color': [VAR_PATTERN, CONTAINS_VAR_PATTERN, 'transparent', 'inherit', 'currentColor'],
'outline-color': [VAR_PATTERN, 'transparent', 'inherit', 'currentColor'],
+167 -1
View File
@@ -3,6 +3,7 @@ import assert from "node:assert/strict";
import { NostrClient } from "../js/nostr.js";
await (async function testDeleteFlowPublishesDeletionFlag() {
localStorage.clear();
const client = new NostrClient();
client.hydrateVideoHistory = async () => {};
@@ -29,9 +30,13 @@ await (async function testDeleteFlowPublishesDeletionFlag() {
client.allEvents.set(baseVideo.id, { ...baseVideo });
const revertCalls = [];
const revertEvents = [];
client.revertVideo = async (event) => {
revertCalls.push(event);
return { id: `revert-${revertCalls.length}` };
const created_at = baseVideo.created_at + 500 + revertCalls.length;
const revertEvent = { id: `revert-${revertCalls.length}`, created_at };
revertEvents.push(revertEvent);
return revertEvent;
};
const result = await client.deleteAllVersions(videoRootId, pubkey, {
@@ -50,6 +55,12 @@ await (async function testDeleteFlowPublishesDeletionFlag() {
assert.equal(cached.deleted, true, "cached original video should be marked deleted");
const activeKey = `ROOT:${videoRootId}`;
const tombstoneValue = client.tombstones.get(activeKey);
assert.equal(
tombstoneValue,
revertEvents[0].created_at,
"deleteAllVersions should seed tombstone with revert timestamp",
);
client.activeMap.set(activeKey, {
...baseVideo,
videoRootId,
@@ -101,6 +112,161 @@ await (async function testDeleteFlowPublishesDeletionFlag() {
const storedDelete = client.allEvents.get(deleteEvent.id);
assert.equal(storedDelete.deleted, true, "stored delete event should be marked deleted");
assert.equal(
client.tombstones.get(activeKey),
deleteEvent.created_at,
"subscription delete event should update tombstone",
);
})();
await (async function testTombstonePersistenceAcrossSaveRestore() {
localStorage.clear();
const client = new NostrClient();
const videoRootId = "root-persist";
const activeKey = `ROOT:${videoRootId}`;
const legacyVideo = {
id: "persist-video",
pubkey: "PUBKEY456",
created_at: 1_700_000_100,
version: 3,
videoRootId,
title: "Persisted",
url: "https://example.com/video.mp4",
magnet: "",
thumbnail: "",
description: "",
mode: "live",
tags: [
["t", "video"],
["d", videoRootId],
],
};
client.allEvents.set(legacyVideo.id, { ...legacyVideo });
client.recordTombstone(activeKey, legacyVideo.created_at + 50);
client.saveLocalData();
const restored = new NostrClient();
restored.restoreLocalData();
assert.equal(
restored.tombstones.get(activeKey),
legacyVideo.created_at + 50,
"tombstone timestamp should persist across save/restore",
);
const restoredVideo = restored.allEvents.get(legacyVideo.id);
assert.equal(restoredVideo.deleted, true, "restored video should remain tombstoned");
assert.equal(
restored.activeMap.has(activeKey),
false,
"restored tombstoned video should not populate activeMap",
);
})();
await (async function testSubscribeVideosSkipsOlderThanTombstone() {
localStorage.clear();
const client = new NostrClient();
client.populateNip71MetadataForVideos = async () => {};
const seenUpdates = [];
const handlers = {};
const fakeSub = {
on(type, handler) {
handlers[type] = handler;
},
unsub() {},
};
client.pool = {
sub() {
return fakeSub;
},
};
client.subscribeVideos((videoUpdate) => {
seenUpdates.push(videoUpdate);
});
const pubkey = "PUBKEY789";
const videoRootId = "root-sub";
const tags = [
["t", "video"],
["d", videoRootId],
];
const deleteEvent = {
id: "delete-event-guard",
pubkey,
created_at: 1_700_100_000,
content: JSON.stringify({
version: 3,
deleted: true,
title: "",
url: "",
magnet: "",
thumbnail: "",
description: "",
mode: "live",
videoRootId,
}),
tags,
};
handlers.event(deleteEvent);
if (typeof handlers.eose === "function") {
handlers.eose();
}
const activeKey = `ROOT:${videoRootId}`;
assert.equal(
client.tombstones.get(activeKey),
deleteEvent.created_at,
"delete subscription event should seed tombstone",
);
assert.equal(
client.activeMap.has(activeKey),
false,
"deleted subscription event should clear active map entry",
);
const olderEvent = {
id: "older-event",
pubkey,
created_at: deleteEvent.created_at - 60,
content: JSON.stringify({
version: 3,
deleted: false,
title: "Resurfaced",
url: "https://example.com/video.mp4",
magnet: "",
thumbnail: "",
description: "",
mode: "live",
videoRootId,
}),
tags,
};
handlers.event(olderEvent);
if (typeof handlers.eose === "function") {
handlers.eose();
}
assert.equal(
seenUpdates.length,
0,
"older events published after tombstone should not trigger callbacks",
);
const storedOlder = client.allEvents.get(olderEvent.id);
assert.equal(storedOlder.deleted, true, "older event should be marked deleted by tombstone guard");
assert.equal(
client.activeMap.has(activeKey),
false,
"older event should not repopulate the active map",
);
})();
console.log("nostr delete flow tests passed");
+5 -3
View File
@@ -741,10 +741,12 @@ test("applies token-based sizing and arrow positioning", async () => {
const panel = documentRef.getElementById("panel-with-arrow");
const arrow = panel.querySelector(".arrow");
assert.equal(panel.style.maxWidth, "320px");
assert.equal(panel.style.maxHeight, "240px");
const panelStyles = getComputedStyle(panel);
assert.equal(panelStyles.maxWidth, "320px");
assert.equal(panelStyles.maxHeight, "240px");
assert.equal(arrow.dataset.popoverArrowSide, "bottom");
assert.notEqual(arrow.style.top, "");
const arrowStyles = getComputedStyle(arrow);
assert.notEqual(arrowStyles.top, "");
popover.destroy();
});
+72 -6
View File
@@ -57,11 +57,44 @@ test.describe("overlay layering tokens", () => {
throw new Error("Missing sidebar layout nodes");
}
const ensureProbeStyles = () => {
if (document.getElementById("sidebar-width-probe-style")) {
return;
}
const style = document.createElement("style");
style.id = "sidebar-width-probe-style";
style.textContent = `
.sidebar-width-probe {
position: absolute;
visibility: hidden;
}
.sidebar-width-probe--collapsed {
width: calc(var(--sidebar-width-collapsed) + var(--sidebar-content-gap));
}
.sidebar-width-probe--expanded {
width: calc(var(--sidebar-width-expanded) + var(--sidebar-content-gap));
}
`;
document.head.appendChild(style);
};
ensureProbeStyles();
const computeMargin = (widthVar) => {
const variant =
widthVar === "--sidebar-width-collapsed"
? "sidebar-width-probe--collapsed"
: widthVar === "--sidebar-width-expanded"
? "sidebar-width-probe--expanded"
: null;
if (!variant) {
throw new Error(`Unsupported sidebar width variable: ${widthVar}`);
}
const probe = document.createElement("div");
probe.style.position = "absolute";
probe.style.visibility = "hidden";
probe.style.width = `calc(var(${widthVar}) + var(--sidebar-content-gap))`;
probe.classList.add("sidebar-width-probe", variant);
document.body.appendChild(probe);
const width = parseFloat(window.getComputedStyle(probe).width);
probe.remove();
@@ -101,11 +134,44 @@ test.describe("overlay layering tokens", () => {
throw new Error("Missing sidebar layout nodes");
}
const ensureProbeStyles = () => {
if (document.getElementById("sidebar-width-probe-style")) {
return;
}
const style = document.createElement("style");
style.id = "sidebar-width-probe-style";
style.textContent = `
.sidebar-width-probe {
position: absolute;
visibility: hidden;
}
.sidebar-width-probe--collapsed {
width: calc(var(--sidebar-width-collapsed) + var(--sidebar-content-gap));
}
.sidebar-width-probe--expanded {
width: calc(var(--sidebar-width-expanded) + var(--sidebar-content-gap));
}
`;
document.head.appendChild(style);
};
ensureProbeStyles();
const computeMargin = (widthVar) => {
const variant =
widthVar === "--sidebar-width-collapsed"
? "sidebar-width-probe--collapsed"
: widthVar === "--sidebar-width-expanded"
? "sidebar-width-probe--expanded"
: null;
if (!variant) {
throw new Error(`Unsupported sidebar width variable: ${widthVar}`);
}
const probe = document.createElement("div");
probe.style.position = "absolute";
probe.style.visibility = "hidden";
probe.style.width = `calc(var(${widthVar}) + var(--sidebar-content-gap))`;
probe.classList.add("sidebar-width-probe", variant);
document.body.appendChild(probe);
const width = parseFloat(window.getComputedStyle(probe).width);
probe.remove();