diff --git a/index.ts b/index.ts index 7cfb67a..4186bc0 100644 --- a/index.ts +++ b/index.ts @@ -7,9 +7,13 @@ import type { import { getCCNPrivateKey, getCCNPubkey, + isAddressableEvent, isArray, + isCCNReplaceableEvent, isLocalhost, + isReplaceableEvent, isValidJSON, + parseATagQuery, randomTimeUpTo2DaysInThePast, } from "./utils.ts"; import * as nostrTools from "@nostr/tools"; @@ -171,6 +175,58 @@ function addEventToDb( if (existingEvent) throw new EventAlreadyExistsException(); try { db.run("BEGIN TRANSACTION"); + + if (isReplaceableEvent(decryptedEvent.kind)) { + sql` + UPDATE events + SET replaced = 1 + WHERE kind = ${decryptedEvent.kind} + AND pubkey = ${decryptedEvent.pubkey} + AND created_at < ${decryptedEvent.created_at} + `(db); + } + + if (isAddressableEvent(decryptedEvent.kind)) { + const dTag = decryptedEvent.tags.find((tag) => tag[0] === "d")?.[1]; + if (dTag) { + sql` + UPDATE events + SET replaced = 1 + WHERE kind = ${decryptedEvent.kind} + AND pubkey = ${decryptedEvent.pubkey} + AND created_at < ${decryptedEvent.created_at} + AND id IN ( + SELECT event_id FROM event_tags + WHERE tag_name = 'd' + AND tag_id IN ( + SELECT tag_id FROM event_tags_values + WHERE value_position = 1 + AND value = ${dTag} + ) + ) + `(db); + } + } + + if (isCCNReplaceableEvent(decryptedEvent.kind)) { + const dTag = decryptedEvent.tags.find((tag) => tag[0] === "d")?.[1]; + sql` + UPDATE events + SET replaced = 1 + WHERE kind = ${decryptedEvent.kind} + AND created_at < ${decryptedEvent.created_at} + AND id IN ( + SELECT event_id FROM event_tags + WHERE tag_name = 'd' + AND tag_id IN ( + SELECT tag_id FROM event_tags_values + WHERE value_position = 1 + AND value = ${dTag} + ) + ) + `(db); + } + sql` INSERT INTO events (id, original_id, pubkey, created_at, kind, content, sig, first_seen) VALUES ( ${decryptedEvent.id}, @@ -353,7 +409,7 @@ function handleRequest(connection: UserConnection, request: NostrClientREQ) { }`, ); - let query = sqlPartial`SELECT * FROM events`; + let query = sqlPartial`SELECT * FROM events WHERE replaced = 0`; const filtersAreNotEmpty = filters.some((filter) => { return Object.values(filter).some((value) => { @@ -362,7 +418,7 @@ function handleRequest(connection: UserConnection, request: NostrClientREQ) { }); if (filtersAreNotEmpty) { - query = mixQuery(query, sqlPartial`WHERE`); + query = mixQuery(query, sqlPartial`AND`); for (let i = 0; i < filters.length; i++) { // filters act as OR, filter groups act as AND @@ -431,22 +487,73 @@ function handleRequest(connection: UserConnection, request: NostrClientREQ) { const uniqueValues = [...new Set(value)]; query = mixQuery(query, sqlPartial`(`); for (let k = 0; k < uniqueValues.length; k++) { - const value = uniqueValues[k] as string; + const tagValue = uniqueValues[k] as string; + if (tag === "a") { + const aTagInfo = parseATagQuery(tagValue); + + if (aTagInfo.dTag && aTagInfo.dTag !== "") { + if (isCCNReplaceableEvent(aTagInfo.kind)) { + // CCN replaceable event reference + query = mixQuery( + query, + sqlPartial`id IN ( + SELECT e.id + FROM events e + JOIN event_tags t ON e.id = t.event_id + JOIN event_tags_values v ON t.tag_id = v.tag_id + WHERE e.kind = ${aTagInfo.kind} + AND t.tag_name = 'd' + AND v.value_position = 1 + AND v.value = ${aTagInfo.dTag} + )`, + ); + } else { + // Addressable event reference + query = mixQuery( + query, + sqlPartial`id IN ( + SELECT e.id + FROM events e + JOIN event_tags t ON e.id = t.event_id + JOIN event_tags_values v ON t.tag_id = v.tag_id + WHERE e.kind = ${aTagInfo.kind} + AND e.pubkey = ${aTagInfo.pubkey} + AND t.tag_name = 'd' + AND v.value_position = 1 + AND v.value = ${aTagInfo.dTag} + )`, + ); + } + } else { + // Replaceable event reference + query = mixQuery( + query, + sqlPartial`id IN ( + SELECT id + FROM events + WHERE kind = ${aTagInfo.kind} + AND pubkey = ${aTagInfo.pubkey} + )`, + ); + } + } else { + // Regular tag handling (unchanged) + query = mixQuery( + query, + sqlPartial`id IN ( + SELECT t.event_id + FROM event_tags t + WHERE t.tag_name = ${tag} + AND t.tag_id IN ( + SELECT v.tag_id + FROM event_tags_values v + WHERE v.value_position = 1 + AND v.value = ${tagValue} + ) + )`, + ); + } - query = mixQuery( - query, - sqlPartial`id IN ( - SELECT t.event_id - FROM event_tags t - WHERE t.tag_name = ${tag} - AND t.tag_id IN ( - SELECT v.tag_id - FROM event_tags_values v - WHERE v.value_position = 1 - AND v.value = ${value} - ) - )`, - ); if (k < uniqueValues.length - 1) { query = mixQuery(query, sqlPartial`OR`); } @@ -481,12 +588,36 @@ function handleRequest(connection: UserConnection, request: NostrClientREQ) { const rawTags = sql`SELECT * FROM event_tags_view WHERE event_id = ${ events[i].id }`(connection.db); - const tags: { [key: string]: string[] } = {}; - for (const item of rawTags) { - if (!tags[item.tag_name]) tags[item.tag_name] = [item.tag_name]; - tags[item.tag_name].push(item.tag_value); + const tagsByIndex = new Map; + }>(); + + for (const tag of rawTags) { + let tagData = tagsByIndex.get(tag.tag_index); + if (!tagData) { + tagData = { + name: tag.tag_name, + values: new Map(), + }; + tagsByIndex.set(tag.tag_index, tagData); + } + + tagData.values.set(tag.tag_value_position, tag.tag_value); } - const tagsArray = Object.values(tags); + + const tagsArray = Array.from(tagsByIndex.entries()) + .sort(([indexA], [indexB]) => indexA - indexB) + .map(([_, tagData]) => { + const { name, values } = tagData; + + return [ + name, + ...Array.from(values.entries()) + .sort(([posA], [posB]) => posA - posB) + .map(([_, value]) => value), + ]; + }); const event = { id: events[i].id, diff --git a/migrations/3-replaceableEvents.sql b/migrations/3-replaceableEvents.sql new file mode 100644 index 0000000..67ba8fc --- /dev/null +++ b/migrations/3-replaceableEvents.sql @@ -0,0 +1,2 @@ +ALTER TABLE events +ADD COLUMN replaced INTEGER NOT NULL DEFAULT 0; diff --git a/utils.ts b/utils.ts index 1ec0095..aecc1fd 100644 --- a/utils.ts +++ b/utils.ts @@ -67,3 +67,35 @@ export async function getCCNPrivateKey(): Promise { ); return decryptUint8Array(decodeBase64(encryptedPrivateKey), encryptionKey); } + +export function isReplaceableEvent(kind: number): boolean { + return (kind >= 10000 && kind < 20000) || kind === 0 || kind === 3; +} + +export function isAddressableEvent(kind: number): boolean { + return kind >= 30000 && kind < 40000; +} + +export function isRegularEvent(kind: number): boolean { + return (kind >= 1000 && kind < 10000) || + (kind >= 4 && kind < 45) || + kind === 1 || + kind === 2; +} + +export function isCCNReplaceableEvent(kind: number): boolean { + return (kind >= 60000 && kind < 65536) || kind === 0; +} + +export function parseATagQuery( + aTagValue: string, +): { kind: number; pubkey: string; dTag?: string } { + const parts = aTagValue.split(":"); + if (parts.length < 2) return { kind: 0, pubkey: "" }; + + return { + kind: Number.parseInt(parts[0], 10), + pubkey: parts[1], + dTag: parts.length > 2 ? parts[2] : undefined, + }; +}