From 4c554c91a6cb983c5af953e4728594fd4a76ca7c Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Mon, 17 Mar 2025 19:14:49 +0100 Subject: [PATCH 1/3] Implement Replaceable and Addressable Events --- index.ts | 97 +++++++++++++++++++++++++++++++++++++++++++++++--------- utils.ts | 26 +++++++++++++++ 2 files changed, 108 insertions(+), 15 deletions(-) diff --git a/index.ts b/index.ts index 7cfb67a..71d54db 100644 --- a/index.ts +++ b/index.ts @@ -7,9 +7,12 @@ import type { import { getCCNPrivateKey, getCCNPubkey, + isAddressableEvent, isArray, isLocalhost, + isReplaceableEvent, isValidJSON, + parseATagQuery, randomTimeUpTo2DaysInThePast, } from "./utils.ts"; import * as nostrTools from "@nostr/tools"; @@ -171,6 +174,36 @@ function addEventToDb( if (existingEvent) throw new EventAlreadyExistsException(); try { db.run("BEGIN TRANSACTION"); + if (isReplaceableEvent(decryptedEvent.kind)) { + sql` + DELETE FROM events + 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` + DELETE FROM events + 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); + } + } + sql` INSERT INTO events (id, original_id, pubkey, created_at, kind, content, sig, first_seen) VALUES ( ${decryptedEvent.id}, @@ -431,22 +464,56 @@ 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 !== "") { + // 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`); } diff --git a/utils.ts b/utils.ts index 1ec0095..5c7c5c6 100644 --- a/utils.ts +++ b/utils.ts @@ -67,3 +67,29 @@ 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 parseATagQuery(aTagValue: string): { kind: number, pubkey: string, dTag?: string } { + const parts = aTagValue.split(':'); + if (parts.length < 2) return { kind: 0, pubkey: '' }; + + return { + kind: parseInt(parts[0], 10), + pubkey: parts[1], + dTag: parts.length > 2 ? parts[2] : undefined + }; +} -- 2.45.3 From 583776d52a682f2978640456fa2707ddbb71c0bb Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Mon, 17 Mar 2025 23:16:47 +0100 Subject: [PATCH 2/3] don't delete replaceable events, replace them, and fix bug with tag array returning --- index.ts | 45 ++++++++++++++++++++++++------ migrations/3-replaceableEvents.sql | 2 ++ 2 files changed, 38 insertions(+), 9 deletions(-) create mode 100644 migrations/3-replaceableEvents.sql diff --git a/index.ts b/index.ts index 71d54db..46962c6 100644 --- a/index.ts +++ b/index.ts @@ -174,9 +174,11 @@ function addEventToDb( if (existingEvent) throw new EventAlreadyExistsException(); try { db.run("BEGIN TRANSACTION"); + if (isReplaceableEvent(decryptedEvent.kind)) { sql` - DELETE FROM events + UPDATE events + SET replaced = 1 WHERE kind = ${decryptedEvent.kind} AND pubkey = ${decryptedEvent.pubkey} AND created_at < ${decryptedEvent.created_at} @@ -187,7 +189,8 @@ function addEventToDb( const dTag = decryptedEvent.tags.find((tag) => tag[0] === "d")?.[1]; if (dTag) { sql` - DELETE FROM events + UPDATE events + SET replaced = 1 WHERE kind = ${decryptedEvent.kind} AND pubkey = ${decryptedEvent.pubkey} AND created_at < ${decryptedEvent.created_at} @@ -386,7 +389,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) => { @@ -395,7 +398,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 @@ -548,12 +551,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; -- 2.45.3 From 65c34e68113e931cd0ede66a51cda4fb41e61066 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Tue, 18 Mar 2025 16:04:38 +0100 Subject: [PATCH 3/3] create CCN replaceable events --- index.ts | 67 +++++++++++++++++++++++++++++++++++++++++++------------- utils.ts | 16 +++++++++----- 2 files changed, 63 insertions(+), 20 deletions(-) diff --git a/index.ts b/index.ts index 46962c6..4186bc0 100644 --- a/index.ts +++ b/index.ts @@ -9,6 +9,7 @@ import { getCCNPubkey, isAddressableEvent, isArray, + isCCNReplaceableEvent, isLocalhost, isReplaceableEvent, isValidJSON, @@ -207,6 +208,25 @@ function addEventToDb( } } + 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}, @@ -472,21 +492,38 @@ function handleRequest(connection: UserConnection, request: NostrClientREQ) { const aTagInfo = parseATagQuery(tagValue); if (aTagInfo.dTag && aTagInfo.dTag !== "") { - // 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} - )`, - ); + 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( diff --git a/utils.ts b/utils.ts index 5c7c5c6..aecc1fd 100644 --- a/utils.ts +++ b/utils.ts @@ -83,13 +83,19 @@ export function isRegularEvent(kind: number): boolean { kind === 2; } -export function parseATagQuery(aTagValue: string): { kind: number, pubkey: string, dTag?: string } { - const parts = aTagValue.split(':'); - if (parts.length < 2) return { kind: 0, pubkey: '' }; +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: parseInt(parts[0], 10), + kind: Number.parseInt(parts[0], 10), pubkey: parts[1], - dTag: parts.length > 2 ? parts[2] : undefined + dTag: parts.length > 2 ? parts[2] : undefined, }; } -- 2.45.3