Upgrade Nostrify to support negative search queries, remove getIdsBySearch function

This commit is contained in:
Alex Gleason 2025-02-11 17:40:28 -06:00
parent f8777b9e09
commit eb94da6cca
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
4 changed files with 4 additions and 162 deletions

View file

@ -48,7 +48,7 @@
"@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1", "@isaacs/ttlcache": "npm:@isaacs/ttlcache@^1.4.1",
"@negrel/webpush": "jsr:@negrel/webpush@^0.3.0", "@negrel/webpush": "jsr:@negrel/webpush@^0.3.0",
"@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0", "@noble/secp256k1": "npm:@noble/secp256k1@^2.0.0",
"@nostrify/db": "jsr:@nostrify/db@^0.38.0", "@nostrify/db": "jsr:@nostrify/db@^0.39.0",
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.38.1", "@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.38.1",
"@nostrify/policies": "jsr:@nostrify/policies@^0.36.1", "@nostrify/policies": "jsr:@nostrify/policies@^0.36.1",
"@nostrify/types": "jsr:@nostrify/types@^0.36.0", "@nostrify/types": "jsr:@nostrify/types@^0.36.0",

View file

@ -11,7 +11,7 @@ import { nip05Cache } from '@/utils/nip05.ts';
import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts';
import { renderStatus } from '@/views/mastodon/statuses.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts';
import { getFollowedPubkeys } from '@/queries.ts'; import { getFollowedPubkeys } from '@/queries.ts';
import { getIdsBySearch, getPubkeysBySearch } from '@/utils/search.ts'; import { getPubkeysBySearch } from '@/utils/search.ts';
const searchQuerySchema = z.object({ const searchQuerySchema = z.object({
q: z.string().transform(decodeURIComponent), q: z.string().transform(decodeURIComponent),
@ -105,13 +105,6 @@ async function searchEvents(
filter.search = undefined; filter.search = undefined;
} }
// For status search, use a specific query so it supports offset and is open to customizations.
if (type === 'statuses') {
const ids = await getIdsBySearch(kysely, { q, limit, offset });
filter.ids = [...ids];
filter.search = undefined;
}
// Results should only be shown from one author. // Results should only be shown from one author.
if (account_id) { if (account_id) {
filter.authors = [account_id]; filter.authors = [account_id];

View file

@ -1,7 +1,7 @@
import { assertEquals } from '@std/assert'; import { assertEquals } from '@std/assert';
import { createTestDB, genEvent } from '@/test.ts'; import { createTestDB } from '@/test.ts';
import { getIdsBySearch, getPubkeysBySearch } from '@/utils/search.ts'; import { getPubkeysBySearch } from '@/utils/search.ts';
Deno.test('fuzzy search works', async () => { Deno.test('fuzzy search works', async () => {
await using db = await createTestDB(); await using db = await createTestDB();
@ -48,47 +48,3 @@ Deno.test('fuzzy search works with offset', async () => {
new Set(), new Set(),
); );
}); });
Deno.test('Searching for posts work', async () => {
await using db = await createTestDB();
const event = genEvent({ content: "I'm not an orphan. Death is my importance", kind: 1 });
await db.store.event(event);
await db.kysely.updateTable('nostr_events').set('search_ext', { language: 'en' }).where('id', '=', event.id)
.execute();
const event2 = genEvent({ content: 'The more I explore is the more I fall in love with the music I make.', kind: 1 });
await db.store.event(event2);
await db.kysely.updateTable('nostr_events').set('search_ext', { language: 'en' }).where('id', '=', event2.id)
.execute();
assertEquals(
await getIdsBySearch(db.kysely, { q: 'Death is my importance', limit: 1, offset: 0 }), // ordered words
new Set([event.id]),
);
assertEquals(
await getIdsBySearch(db.kysely, { q: 'make I music', limit: 1, offset: 0 }), // reversed words
new Set([event2.id]),
);
assertEquals(
await getIdsBySearch(db.kysely, { q: 'language:en make I music', limit: 10, offset: 0 }), // reversed words, english
new Set([event2.id]),
);
assertEquals(
await getIdsBySearch(db.kysely, { q: 'language:en an orphan', limit: 10, offset: 0 }), // all posts in english plus search
new Set([event.id]),
);
assertEquals(
await getIdsBySearch(db.kysely, { q: 'language:en', limit: 10, offset: 0 }), // all posts in english
new Set([event.id, event2.id]),
);
assertEquals(
await getIdsBySearch(db.kysely, { q: '', limit: 10, offset: 0 }),
new Set(),
);
});

View file

@ -1,7 +1,6 @@
import { Kysely, sql } from 'kysely'; import { Kysely, sql } from 'kysely';
import { DittoTables } from '@/db/DittoTables.ts'; import { DittoTables } from '@/db/DittoTables.ts';
import { NIP50 } from '@nostrify/nostrify';
/** Get pubkeys whose name and NIP-05 is similar to 'q' */ /** Get pubkeys whose name and NIP-05 is similar to 'q' */
export async function getPubkeysBySearch( export async function getPubkeysBySearch(
@ -33,109 +32,3 @@ export async function getPubkeysBySearch(
return new Set(Array.from(followingPubkeys.union(pubkeys))); return new Set(Array.from(followingPubkeys.union(pubkeys)));
} }
/**
* Get kind 1 ids whose content matches `q`.
* It supports NIP-50 extensions.
*/
export async function getIdsBySearch(
kysely: Kysely<DittoTables>,
opts: { q: string; limit: number; offset: number },
): Promise<Set<string>> {
const { q, limit, offset } = opts;
const [lexemes] = (await sql<{ phraseto_tsquery: 'string' }>`SELECT phraseto_tsquery(${q})`.execute(kysely)).rows;
// if it's just stop words, don't bother making a request to the database
if (!lexemes.phraseto_tsquery) {
return new Set();
}
const tokens = NIP50.parseInput(q);
const ext: Record<string, string[]> = {};
const txt = tokens.filter((token) => typeof token === 'string').join(' ');
let query = kysely
.selectFrom('nostr_events')
.select('id')
.where('kind', '=', 1)
.orderBy(['created_at desc'])
.limit(limit)
.offset(offset);
const domains = new Set<string>();
for (const token of tokens) {
if (typeof token === 'object' && token.key === 'domain') {
domains.add(token.value);
}
}
for (const token of tokens) {
if (typeof token === 'object') {
ext[token.key] ??= [];
ext[token.key].push(token.value);
}
}
for (let [key, values] of Object.entries(ext)) {
if (key === 'domain' || key === '-domain') continue;
let negated = false;
if (key.startsWith('-')) {
key = key.slice(1);
negated = true;
}
query = query.where((eb) => {
if (negated) {
return eb.and(
values.map((value) => eb.not(eb('nostr_events.search_ext', '@>', { [key]: value }))),
);
} else {
return eb.or(
values.map((value) => eb('nostr_events.search_ext', '@>', { [key]: value })),
);
}
});
}
if (domains.size) {
const pubkeys = (await kysely
.selectFrom('pubkey_domains')
.select('pubkey')
.where('domain', 'in', [...domains])
.execute()).map(({ pubkey }) => pubkey);
query = query.where('pubkey', 'in', pubkeys);
}
// If there is not a specific content to search, return the query already
// This is useful if the person only makes a query search such as `domain:patrickdosreis.com`
if (!txt.length) {
const ids = new Set((await query.execute()).map(({ id }) => id));
return ids;
}
let fallbackQuery = query;
if (txt) {
query = query.where('search', '@@', sql`phraseto_tsquery(${txt})`);
}
const ids = new Set((await query.execute()).map(({ id }) => id));
// If there is no ids, fallback to `plainto_tsquery`
if (!ids.size) {
fallbackQuery = fallbackQuery.where(
'search',
'@@',
sql`plainto_tsquery(${txt})`,
);
const ids = new Set((await fallbackQuery.execute()).map(({ id }) => id));
return ids;
}
return ids;
}