From bd93313766cac1a8aa79305875fb66da3f9760ba Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Mon, 16 Mar 2026 14:59:57 -0400 Subject: [PATCH 1/4] First pass using pg only for search --- client/containers/App/paths.ts | 6 + client/containers/Search2/Search2.tsx | 478 +++++++++++++++++++++++++ client/containers/Search2/search2.scss | 300 ++++++++++++++++ client/containers/index.ts | 1 + infra/docker-compose.dev.yml | 62 ++-- server/apiRoutes.ts | 2 + server/pubAttribution/model.ts | 10 +- server/routes/index.ts | 2 + server/routes/search2.tsx | 37 ++ server/search2/api.ts | 51 +++ server/search2/queries.ts | 477 ++++++++++++++++++++++++ server/sequelize.ts | 12 +- 12 files changed, 1405 insertions(+), 33 deletions(-) create mode 100644 client/containers/Search2/Search2.tsx create mode 100644 client/containers/Search2/search2.scss create mode 100644 server/routes/search2.tsx create mode 100644 server/search2/api.ts create mode 100644 server/search2/queries.ts diff --git a/client/containers/App/paths.ts b/client/containers/App/paths.ts index 6b9a6a28de..d943db19d8 100644 --- a/client/containers/App/paths.ts +++ b/client/containers/App/paths.ts @@ -33,6 +33,7 @@ import { Pricing, Pub, Search, + Search2, Signup, SuperAdminDashboard, User, @@ -183,6 +184,11 @@ export default (viewData, locationData, chunkName) => { hideNav: locationData.isBasePubPub, hideFooter: true, }, + Search2: { + ActiveComponent: Search2, + hideNav: locationData.isBasePubPub, + hideFooter: true, + }, Signup: { ActiveComponent: Signup, hideNav: true, diff --git a/client/containers/Search2/Search2.tsx b/client/containers/Search2/Search2.tsx new file mode 100644 index 0000000000..bf742ed0d6 --- /dev/null +++ b/client/containers/Search2/Search2.tsx @@ -0,0 +1,478 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; + +import { Button, Checkbox, Classes, InputGroup, NonIdealState, Spinner, Tab, Tabs } from '@blueprintjs/core'; + +import { Icon } from 'components'; +import { usePageContext, useThrottled } from 'utils/hooks'; +import { getResizedUrl } from 'utils/images'; +import { generatePubBackground } from 'utils/pubs'; + +import './search2.scss'; + +/* ---------- types ---------- */ +type PubResult = { + id: string; + title: string; + slug: string; + avatar: string | null; + description: string | null; + byline: string | null; + communityId: string; + communityTitle: string; + communitySlug: string; + communityDomain: string | null; + communityAvatar: string | null; + communityAccentColorDark: string | null; + communityAccentColorLight: string | null; + communityHeaderLogo: string | null; + communityTextColor: string | null; +}; + +type CommunityResult = { + id: string; + title: string; + subdomain: string; + domain: string | null; + description: string | null; + avatar: string | null; + accentColorDark: string | null; + headerLogo: string | null; + pubCount: number; +}; + +type AuthorFacet = { name: string; count: number }; +type SearchMode = 'pubs' | 'communities'; +type FieldKey = 'title' | 'description' | 'byline' | 'content'; + +const ALL_FIELDS: { key: FieldKey; label: string }[] = [ + { key: 'title', label: 'Title' }, + { key: 'description', label: 'Description' }, + { key: 'byline', label: 'Authors' }, + { key: 'content', label: 'Full text' }, +]; + +const PAGE_SIZE = 20; + +/* ---------- helpers ---------- */ +const getSearchPath = (query: string, page: number, mode: string) => { + const params = new URLSearchParams(); + if (query) params.append('q', query); + if (page > 0) params.append('page', String(page + 1)); + if (mode !== 'pubs') params.append('mode', mode); + const qs = params.toString(); + return `/search2${qs ? `?${qs}` : ''}`; +}; + +const updateHistory = (query: string, page: number, mode: string) => { + window.history.replaceState({}, '', getSearchPath(query, page, mode)); +}; + +const getCommunityUrl = (r: { communityDomain: string | null; communitySlug: string }) => + r.communityDomain ? `https://${r.communityDomain}` : `https://${r.communitySlug}.pubpub.org`; + +/* ---------- Pub result row (matches /search style) ---------- */ +const PubResultRow = ({ item, isBasePubPub }: { item: PubResult; isBasePubPub: boolean }) => { + const communityUrl = getCommunityUrl(item); + const link = `${communityUrl}/pub/${item.slug}`; + const resizedBanner = getResizedUrl(item.avatar, 'inside', 800); + const resizedCommunityLogo = getResizedUrl(item.communityAvatar, 'inside', 125, 35); + const bannerStyle = item.avatar + ? { backgroundImage: `url("${resizedBanner}")` } + : { background: generatePubBackground(item.id) }; + + return ( +
+
+ +
+ +
+
+
+ + {item.title} + + {isBasePubPub && ( + + )} +
+ {item.byline &&
{item.byline}
} + {item.description &&
{item.description}
} +
+
+ ); +}; + +/* ---------- Community result row ---------- */ +const CommunityResultRow = ({ item }: { item: CommunityResult }) => { + const url = item.domain ? `https://${item.domain}` : `https://${item.subdomain}.pubpub.org`; + const resizedAvatar = getResizedUrl(item.avatar, 'inside', 200); + return ( +
+
+ +
+ +
+
+ + {item.description &&
{item.description}
} +
+ + + {item.pubCount} published pub{item.pubCount !== 1 ? 's' : ''} + +
+
+
+ ); +}; + +/* ---------- Sidebar ---------- */ +const SearchSidebar = ({ + fields, + onFieldToggle, + authors, + activeAuthor, + onAuthorClick, +}: { + fields: Set; + onFieldToggle: (f: FieldKey) => void; + authors: AuthorFacet[]; + activeAuthor: string | null; + onAuthorClick: (name: string | null) => void; +}) => ( + +); + +/* ---------- Main component ---------- */ +const Search2 = () => { + const { locationData, communityData } = usePageContext(); + const isBasePubPub = locationData.isBasePubPub; + + const [searchQuery, setSearchQuery] = useState(locationData.query.q || ''); + const [results, setResults] = useState([]); + const [total, setTotal] = useState(0); + const [isLoading, setIsLoading] = useState(!!locationData.query.q); + const [page, setPage] = useState( + locationData.query.page ? Number(locationData.query.page) - 1 : 0, + ); + const initialMode = locationData.query.mode || 'pubs'; + const [mode, setMode] = useState( + initialMode === 'communities' && isBasePubPub ? 'communities' : 'pubs', + ); + + // Facet state + const [fields, setFields] = useState>( + new Set(['title', 'description', 'byline'] as FieldKey[]), + ); + const [authorFacets, setAuthorFacets] = useState([]); + const [activeAuthor, setActiveAuthor] = useState(null); + const [sidebarOpen, setSidebarOpen] = useState(true); + + const throttledQuery = useThrottled(searchQuery, 400, false); + const inputRef = useRef(null); + const abortRef = useRef(null); + + useEffect(() => { + const input = inputRef.current; + if (input) { + input.focus(); + const old = input.value; + input.value = ''; + input.value = old; + } + }, []); + + const handleFieldToggle = useCallback((key: FieldKey) => { + setFields((prev) => { + const next = new Set(prev); + if (next.has(key)) { + if (next.size > 1) next.delete(key); + } else { + next.add(key); + } + return next; + }); + setPage(0); + }, []); + + const handleAuthorClick = useCallback((name: string | null) => { + setActiveAuthor(name); + setPage(0); + }, []); + + const doSearch = useCallback( + async (q: string, p: number, m: string, f: Set, auth: string | null) => { + if (!q.trim()) { + setResults([]); + setTotal(0); + setAuthorFacets([]); + setIsLoading(false); + return; + } + + if (abortRef.current) abortRef.current.abort(); + const controller = new AbortController(); + abortRef.current = controller; + setIsLoading(true); + + const params = new URLSearchParams({ + q: q.trim(), + mode: m, + page: String(p), + limit: String(PAGE_SIZE), + }); + + if (m === 'pubs') { + const fieldArr = Array.from(f); + params.append('fields', fieldArr.join(',')); + if (auth) params.append('author', auth); + if (!isBasePubPub) params.append('communityId', communityData.id); + } + + try { + const resp = await fetch(`/api/search2?${params}`, { + signal: controller.signal, + credentials: 'include', + }); + if (!resp.ok) throw new Error('Search failed'); + const data = await resp.json(); + if (!controller.signal.aborted) { + setResults(data.results); + setTotal(data.total); + if (data.facets?.authors) setAuthorFacets(data.facets.authors); + setIsLoading(false); + } + } catch (err: any) { + if (err.name !== 'AbortError') { + setIsLoading(false); + setResults([]); + setTotal(0); + } + } + }, + [isBasePubPub, communityData.id], + ); + + useEffect(() => { + setActiveAuthor(null); + setPage(0); + }, [throttledQuery]); + + useEffect(() => { + doSearch(throttledQuery, page, mode, fields, activeAuthor); + updateHistory(throttledQuery, page, mode); + }, [throttledQuery, page, mode, fields, activeAuthor, doSearch]); + + const numPages = Math.min(Math.ceil(total / PAGE_SIZE), 10); + const searchString = getSearchPath(throttledQuery, page, mode); + const showSidebar = mode === 'pubs' && sidebarOpen && !!throttledQuery.trim(); + const pages = new Array(numPages).fill(''); + + return ( +
+
+
+
+
+

Search {communityData.title}

+ {!isBasePubPub && ( + + Search all PubPub Communities + + )} +
+ { + setSearchQuery(e.target.value); + setPage(0); + }} + rightElement={isLoading ? : undefined} + inputRef={inputRef as any} + /> +
+
+
+
+
+ { + setMode(nextMode as SearchMode); + setPage(0); + setResults([]); + }} + selectedTabId={mode} + large={true} + animate={false} + > + + {isBasePubPub && } + + {mode === 'pubs' && throttledQuery.trim() && ( + + )} +
+
+
+
+
+
+ {showSidebar && ( + + )} +
+ {/* Active filter summary */} + {!!results.length && (activeAuthor || numPages > 1) && ( +

+ {total} result{total !== 1 ? 's' : ''} + {activeAuthor && ( + <> + {' '} + by {activeAuthor} + + )} + {numPages > 1 && ` · Page ${page + 1} of ${numPages}`} +

+ )} + + {/* No results */} + {!results.length && searchQuery && !isLoading && ( + + )} + + {/* Result rows */} + {!!results.length && ( +
+ {mode === 'pubs' && + results.map((item: PubResult) => ( + + ))} + {mode === 'communities' && + results.map((item: CommunityResult) => ( + + ))} + + {/* Pagination */} + {numPages > 1 && ( +
+ {pages.map((_: any, index: number) => { + const key = `page-button-${index}`; + return ( +
+ )} +
+ )} +
+
+
+
+
+
+ ); +}; + +export default Search2; diff --git a/client/containers/Search2/search2.scss b/client/containers/Search2/search2.scss new file mode 100644 index 0000000000..453090affd --- /dev/null +++ b/client/containers/Search2/search2.scss @@ -0,0 +1,300 @@ +@use 'styles/vendor.scss'; + +$bp: vendor.$bp-namespace; +$accent: #5c7cfa; + +// Re-use the exact same #search-container styles from /search +#search-container { + padding: 2em 0em; + + .search-header { + display: flex; + align-items: center; + h2 { + flex: 1 1 auto; + font-weight: 800; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + a { + white-space: nowrap; + flex: 0 0 auto; + margin-left: 1em; + text-decoration: none; + } + } + + .#{$bp}-input-group input { + margin-bottom: 1em; + font-size: 35px; + font-weight: 200; + height: 70px; + line-height: 70px; + } + + .#{$bp}-input-action { + margin-top: 7px; + } + + .#{$bp}-tabs { + border-bottom: 1px solid #ccc; + } + + .#{$bp}-spinner { + width: 35px; + margin: 13px; + } + + // ---- tabs + filter toggle row ---- + .tabs-filter-row { + display: flex; + align-items: flex-end; + justify-content: space-between; + + .#{$bp}-tabs { + flex: 1 1 auto; + } + } + + .filter-toggle { + background: none; + border: 1px solid #dfe3e8; + border-bottom: none; + border-radius: 6px 6px 0 0; + padding: 6px 12px; + font-size: 13px; + font-weight: 500; + color: #637381; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 5px; + margin-bottom: -1px; + transition: background 0.12s, color 0.12s; + &:hover { + background: #f4f6f8; + color: #212b36; + } + } + + // ---- result rows (same as /search) ---- + .result { + margin-bottom: 3em; + display: flex; + font-size: 16px; + a { + text-decoration: none; + } + &.pubs { + .banner-image { + width: 100px; + height: 100px; + } + } + &.communities { + .banner-image { + width: 100px; + height: 100px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + } + } + .banner-image { + background-size: cover; + background-repeat: no-repeat; + background-position: center center; + background-color: #f0f0f0; + margin-right: 1em; + border-radius: 2px; + } + .content { + flex: 1 1 auto; + } + .title { + font-weight: 800; + display: flex; + svg { + margin: -5px 0px 0px 5px; + } + .pub-title { + flex: 1 1 auto; + font-size: 22px; + word-break: break-word; + } + .community-title { + max-width: 40%; + font-size: 14px; + margin-left: 1em; + a { + padding: 0.5em; + white-space: nowrap; + border-radius: 2px; + display: block; + overflow: hidden; + text-overflow: ellipsis; + } + } + } + .byline { + font-style: italic; + margin: 0.5em 0em; + } + .community-meta { + font-size: 12px; + color: #919eab; + display: inline-flex; + align-items: center; + gap: 4px; + margin-top: 0.5em; + } + } + + // ---- two-column layout ---- + .search2-layout { + display: flex; + gap: 32px; + padding-top: 1.5em; + + &.with-sidebar { + .search2-results { + flex: 1; + min-width: 0; + } + } + } + + .search2-results { + flex: 1; + min-width: 0; + } + + .search2-count { + font-size: 13px; + color: #919eab; + margin: 0 0 1.5em; + font-weight: 500; + strong { + color: #454f5b; + } + } + + // ---- sidebar ---- + .search2-sidebar { + flex: 0 0 200px; + width: 200px; + padding-top: 4px; + } + + .sidebar-section { + margin-bottom: 24px; + } + + .sidebar-heading { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #919eab; + margin: 0 0 10px; + display: flex; + align-items: center; + justify-content: space-between; + } + + .sidebar-clear { + background: none; + border: none; + font-size: 11px; + font-weight: 600; + color: $accent; + cursor: pointer; + text-transform: none; + letter-spacing: 0; + &:hover { + text-decoration: underline; + } + } + + .sidebar-checkbox { + margin-bottom: 6px; + .#{$bp}-control-indicator { + border-radius: 4px; + } + } + + .sidebar-facet-list { + list-style: none; + padding: 0; + margin: 0; + } + + .facet-btn { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + background: none; + border: none; + padding: 5px 8px; + border-radius: 6px; + font-size: 13px; + color: #454f5b; + cursor: pointer; + transition: background 0.1s; + text-align: left; + &:hover { + background: #f4f6f8; + } + &.active { + background: rgba($accent, 0.08); + color: $accent; + font-weight: 600; + } + } + + .facet-name { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin-right: 8px; + } + + .facet-count { + flex: 0 0 auto; + font-size: 11px; + color: #919eab; + background: #f4f6f8; + padding: 1px 6px; + border-radius: 10px; + font-weight: 600; + .active & { + background: rgba($accent, 0.12); + color: $accent; + } + } +} + +// ---- responsive ---- +@media (max-width: 768px) { + #search-container { + .search2-layout.with-sidebar { + flex-direction: column; + } + .search2-sidebar { + flex: none; + width: 100%; + border-bottom: 1px solid #ebeef1; + padding-bottom: 16px; + margin-bottom: 8px; + } + .tabs-filter-row { + flex-wrap: wrap; + gap: 8px; + } + } +} diff --git a/client/containers/index.ts b/client/containers/index.ts index 4a56db26ab..83442f6d9c 100644 --- a/client/containers/index.ts +++ b/client/containers/index.ts @@ -34,6 +34,7 @@ export { default as PasswordReset } from './PasswordReset/PasswordReset'; export { default as Pricing } from './Pricing/Pricing'; export { default as Pub } from './Pub/Pub'; export { default as Search } from './Search/Search'; +export { default as Search2 } from './Search2/Search2'; export { default as Signup } from './Signup/Signup'; export { default as SuperAdminDashboard } from './SuperAdminDashboard'; export { default as User } from './User/User'; diff --git a/infra/docker-compose.dev.yml b/infra/docker-compose.dev.yml index af0fea0874..05ae4843fa 100644 --- a/infra/docker-compose.dev.yml +++ b/infra/docker-compose.dev.yml @@ -8,15 +8,15 @@ services: target: dev env_file: - .env.dev - healthcheck: - test: - - CMD-SHELL - - > - curl -f http://localhost:3000 - interval: 10s - retries: 5 - start_period: 120s - timeout: 3s + # healthcheck: + # test: + # - CMD-SHELL + # - > + # curl -f http://localhost:3000 + # interval: 10s + # retries: 5 + # start_period: 120s + # timeout: 3s environment: IS_DUQDUQ: "" NODE_ENV: development @@ -49,8 +49,8 @@ services: condition: service_started rabbitmq: condition: service_started - app: - condition: service_healthy + # app: + # condition: service_healthy networks: [appnet] volumes: - ..:/app @@ -81,32 +81,32 @@ services: -c work_mem=16MB -c maintenance_work_mem=512MB volumes: - - ./pgdata:/var/lib/postgresql/data + - pgdata:/var/lib/postgresql/data ports: - "${DB_PORT:-5439}:5432" networks: [appnet] - cron: - build: - context: .. - dockerfile: Dockerfile - target: dev - env_file: - - .env.dev - environment: - NODE_ENV: development - command: ["pnpm", "run", "cron"] - networks: [appnet] - volumes: - - ..:/app - - /app/node_modules - deploy: - replicas: 1 - restart_policy: - condition: any + # cron: + # build: + # context: .. + # dockerfile: Dockerfile + # target: dev + # env_file: + # - .env.dev + # environment: + # NODE_ENV: development + # command: ["pnpm", "run", "cron"] + # networks: [appnet] + # volumes: + # - ..:/app + # - /app/node_modules + # deploy: + # replicas: 1 + # restart_policy: + # condition: any networks: appnet: driver: bridge volumes: - # pgdata: + pgdata: rabbitmqdata: diff --git a/server/apiRoutes.ts b/server/apiRoutes.ts index 53be2d4829..772b4cec09 100644 --- a/server/apiRoutes.ts +++ b/server/apiRoutes.ts @@ -24,6 +24,7 @@ import { router as reviewRouter } from './review/api'; import { router as reviewerRouter } from './reviewer/api'; import { router as rssRouter } from './rss/api'; import { router as searchRouter } from './search/api'; +import { router as search2Router } from './search2/api'; import { router as signupRouter } from './signup/api'; import { router as spamTagRouter } from './spamTag/api'; import { router as submissionRouter } from './submission/api'; @@ -57,6 +58,7 @@ const apiRouter = Router() .use(reviewerRouter) .use(rssRouter) .use(searchRouter) + .use(search2Router) .use(signupRouter) .use(spamTagRouter) .use(subscribeRouter) diff --git a/server/pubAttribution/model.ts b/server/pubAttribution/model.ts index 6a887bdd0e..8c0cf38b8e 100644 --- a/server/pubAttribution/model.ts +++ b/server/pubAttribution/model.ts @@ -16,7 +16,15 @@ import { import { Pub, User } from '../models'; -@Table +@Table({ + indexes: [ + { + name: 'pubattributions_pubid_isauthor_idx', + fields: ['pubId'], + where: { isAuthor: true }, + }, + ], +}) export class PubAttribution extends Model< InferAttributes, InferCreationAttributes diff --git a/server/routes/index.ts b/server/routes/index.ts index 2b61caa0f2..1ba53fbbf5 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -44,6 +44,7 @@ import { router as pageRouter } from './page'; // Route: ['/', '/:slug'] import { router as passwordResetRouter } from './passwordReset'; // Route: ['/password-reset', '/password-reset/:resetHash/:slug'] import { router as robotsRouter } from './robots'; // Route: /robots.txt import { router as searchRouter } from './search'; // Route: '/search' +import { router as search2Router } from './search2'; // Route: '/search2' import { router as signupRouter } from './signup'; // Route: '/signup' import { router as sitemapRouter } from './sitemap'; // Route: /sitemap-*.xml import { router as superAdminDashboardRouter } from './superAdminDashboard'; // Route: /superadmin @@ -84,6 +85,7 @@ rootRouter .use(authenticateRouter) .use(legalRouter) .use(searchRouter) + .use(search2Router) .use(signupRouter) .use(superAdminDashboardRouter) .use(passwordResetRouter) diff --git a/server/routes/search2.tsx b/server/routes/search2.tsx new file mode 100644 index 0000000000..2175e8cff7 --- /dev/null +++ b/server/routes/search2.tsx @@ -0,0 +1,37 @@ +import type { InitialData } from 'types'; + +import React from 'react'; + +import { Router } from 'express'; + +import { getCustomScriptsForCommunity } from 'server/customScript/queries'; +import Html from 'server/Html'; +import { handleErrors } from 'server/utils/errors'; +import { getInitialData } from 'server/utils/initData'; +import { generateMetaComponents, renderToNodeStream } from 'server/utils/ssr'; + +export const router = Router(); + +router.get('/search2', async (req, res, next) => { + try { + const initialData = await getInitialData(req); + const customScripts = await getCustomScriptsForCommunity(initialData.communityData.id); + + return renderToNodeStream( + res, + , + ); + } catch (err) { + return handleErrors(req, res, next)(err); + } +}); diff --git a/server/search2/api.ts b/server/search2/api.ts new file mode 100644 index 0000000000..54b2d88cb6 --- /dev/null +++ b/server/search2/api.ts @@ -0,0 +1,51 @@ +import { Router } from 'express'; + +import { type SearchFields, searchCommunities, searchPubs } from './queries'; + +export const router = Router(); + +const ALL_FIELDS: SearchFields[] = ['title', 'description', 'byline', 'content']; + +router.get('/api/search2', async (req, res) => { + try { + const q = (req.query.q as string) || ''; + const mode = (req.query.mode as string) || 'pubs'; + const page = Math.max(0, parseInt(req.query.page as string, 10) || 0); + const limit = Math.min(50, Math.max(1, parseInt(req.query.limit as string, 10) || 20)); + const offset = page * limit; + const communityId = req.query.communityId as string | undefined; + const author = (req.query.author as string) || undefined; + + // Parse fields param: comma-separated, validated against known fields + let fields: SearchFields[] | undefined; + if (req.query.fields) { + const raw = (req.query.fields as string).split(','); + fields = raw.filter((f): f is SearchFields => + ALL_FIELDS.includes(f as SearchFields), + ); + if (fields.length === 0) fields = undefined; + } + + if (!q.trim()) { + return res.status(200).json({ results: [], total: 0, page, limit, facets: { authors: [] } }); + } + + if (mode === 'communities') { + const { results, total } = await searchCommunities(q, { limit, offset }); + return res.status(200).json({ results, total, page, limit, facets: { authors: [] } }); + } + + // Default: pubs + const { results, total, facets } = await searchPubs(q, { + limit, + offset, + communityId, + fields, + author, + }); + return res.status(200).json({ results, total, page, limit, facets }); + } catch (err) { + console.error('Error in search2 API:', err); + return res.status(500).json({ error: 'Search failed' }); + } +}); diff --git a/server/search2/queries.ts b/server/search2/queries.ts new file mode 100644 index 0000000000..b06122aec5 --- /dev/null +++ b/server/search2/queries.ts @@ -0,0 +1,477 @@ +import { QueryTypes } from 'sequelize'; + +import { sequelize } from 'server/sequelize'; + +export type PubSearchResult = { + id: string; + title: string; + slug: string; + avatar: string | null; + description: string | null; + customPublishedAt: string | null; + communityId: string; + communityTitle: string; + communitySlug: string; + communityDomain: string | null; + communityAvatar: string | null; + communityAccentColorDark: string | null; + communityAccentColorLight: string | null; + communityHeaderLogo: string | null; + communityTextColor: string | null; + byline: string | null; + rank: number; +}; + +export type CommunitySearchResult = { + id: string; + title: string; + subdomain: string; + domain: string | null; + description: string | null; + avatar: string | null; + accentColorDark: string | null; + headerLogo: string | null; + pubCount: number; + rank: number; +}; + +export type SearchFields = 'title' | 'description' | 'byline' | 'content'; + +type AuthorFacet = { name: string; count: number }; + +/** + * Sanitize user input into a tsquery with prefix matching. + * e.g. "hello world" -> "hello:* & world:*" + */ +const buildTsQuery = (searchTerm: string): string | null => { + const sanitized = searchTerm.trim().replace(/[^\w\s-]/g, ''); + const terms = sanitized.split(/\s+/).filter(Boolean); + if (terms.length === 0) return null; + return terms.map((w) => `${w}:*`).join(' & '); +}; + +/** + * Search pubs using PostgreSQL full-text search. + * + * Strategy: First find matching pubs via title/description/byline (fast), + * then optionally search release doc content for additional matches. + * The two result sets are unioned and ranked together. + * + * Only returns publicly released pubs. Excludes spam communities. + */ +export const searchPubs = async ( + searchTerm: string, + { + limit = 20, + offset = 0, + communityId, + fields, + author, + }: { + limit?: number; + offset?: number; + communityId?: string; + fields?: SearchFields[]; + author?: string; + } = {}, +): Promise<{ results: PubSearchResult[]; total: number; facets: { authors: AuthorFacet[] } }> => { + const tsQuery = buildTsQuery(searchTerm); + if (!tsQuery) return { results: [], total: 0, facets: { authors: [] } }; + + const searchFields = fields && fields.length > 0 ? fields : ['title', 'description', 'byline']; + const searchTitle = searchFields.includes('title'); + const searchDescription = searchFields.includes('description'); + const searchByline = searchFields.includes('byline'); + const searchContent = searchFields.includes('content'); + + const communityFilter = communityId ? `AND p."communityId" = :communityId` : ''; + const authorFilter = author ? `AND EXISTS (SELECT 1 FROM "PubAttributions" af LEFT JOIN "Users" au ON au.id = af."userId" WHERE af."pubId" = p.id AND af."isAuthor" = true AND (af.name ILIKE :authorFilter OR au."fullName" ILIKE :authorFilter))` : ''; + const ilikeTerm = `%${searchTerm.trim().replace(/[%_]/g, '')}%`; + + // Two-phase approach: + // Phase 1 (metadata): Search title, description, byline with tsvector + ILIKE fallback + // Phase 2 (content): Search doc text extracted from latest release — uses a + // targeted LATERAL extraction only on candidate docs, not all Docs rows. + // We UNION these and deduplicate, taking the max rank. + const query = ` + WITH latest_release AS ( + SELECT DISTINCT ON (r."pubId") r."pubId", r."docId" + FROM "Releases" r + ORDER BY r."pubId", r."createdAt" DESC + ), + pub_bylines AS ( + SELECT pa."pubId", + string_agg(COALESCE(u."fullName", pa.name), ', ' ORDER BY pa."order") AS byline + FROM "PubAttributions" pa + LEFT JOIN "Users" u ON u.id = pa."userId" + INNER JOIN latest_release lr2 ON lr2."pubId" = pa."pubId" + WHERE pa."isAuthor" = true + AND (pa.name IS NOT NULL OR u."fullName" IS NOT NULL) + GROUP BY pa."pubId" + ), + released_pubs AS ( + SELECT + p.id, + p.title, + p.slug, + p.avatar, + p.description, + p."customPublishedAt", + c.id AS "communityId", + c.title AS "communityTitle", + c.subdomain AS "communitySlug", + c.domain AS "communityDomain", + c.avatar AS "communityAvatar", + c."accentColorDark" AS "communityAccentColorDark", + c."accentColorLight" AS "communityAccentColorLight", + c."headerLogo" AS "communityHeaderLogo", + c."accentTextColor" AS "communityTextColor", + pb.byline, + lr."docId" + FROM "Pubs" p + INNER JOIN "Communities" c ON c.id = p."communityId" + INNER JOIN latest_release lr ON lr."pubId" = p.id + LEFT JOIN pub_bylines pb ON pb."pubId" = p.id + LEFT JOIN "SpamTags" st ON st.id = c."spamTagId" + WHERE (st.status IS NULL OR st.status != 'confirmed') + ${communityFilter} + ${authorFilter} + ), + -- Phase 1: metadata search (title, description, byline) + metadata_matches AS ( + SELECT + rp.*, + ts_rank_cd( + ${searchTitle ? `setweight(to_tsvector('english', coalesce(rp.title, '')), 'A')` : `to_tsvector('')`} || + ${searchDescription ? `setweight(to_tsvector('english', coalesce(rp.description, '')), 'B')` : `to_tsvector('')`} || + ${searchByline ? `setweight(to_tsvector('english', coalesce(rp.byline, '')), 'C')` : `to_tsvector('')`}, + to_tsquery('english', :tsQuery) + ) AS rank + FROM released_pubs rp + WHERE ( + ${searchTitle ? `setweight(to_tsvector('english', coalesce(rp.title, '')), 'A')` : `to_tsvector('')`} || + ${searchDescription ? `setweight(to_tsvector('english', coalesce(rp.description, '')), 'B')` : `to_tsvector('')`} || + ${searchByline ? `setweight(to_tsvector('english', coalesce(rp.byline, '')), 'C')` : `to_tsvector('')`} + ) @@ to_tsquery('english', :tsQuery) + ${searchTitle ? `OR rp.title ILIKE :ilikeTerm` : ''} + ), + ${searchContent ? ` + -- Phase 2: full-text content search (only for pubs not already matched) + content_matches AS ( + SELECT + rp.*, + ts_rank_cd( + to_tsvector('english', coalesce(doc_plain.plain_text, '')), + to_tsquery('english', :tsQuery) + ) * 0.5 AS rank + FROM released_pubs rp + INNER JOIN LATERAL ( + SELECT string_agg(elem.txt, ' ') AS plain_text + FROM ( + WITH RECURSIVE nodes(node) AS ( + SELECT d.content FROM "Docs" d WHERE d.id = rp."docId" + UNION ALL + SELECT jsonb_array_elements(nodes.node->'content') + FROM nodes + WHERE nodes.node->'content' IS NOT NULL + AND jsonb_typeof(nodes.node->'content') = 'array' + ) + SELECT node->>'text' AS txt + FROM nodes + WHERE node->>'text' IS NOT NULL + ) elem + ) doc_plain ON true + WHERE rp.id NOT IN (SELECT mm.id FROM metadata_matches mm) + AND to_tsvector('english', coalesce(doc_plain.plain_text, '')) + @@ to_tsquery('english', :tsQuery) + ),` : ''} + -- Combine both phases + combined AS ( + SELECT * FROM metadata_matches + ${searchContent ? `UNION ALL SELECT * FROM content_matches` : ''} + ), + facet_data AS ( + SELECT json_agg(sub) AS facets FROM ( + SELECT COALESCE(u."fullName", pa.name) AS name, count(DISTINCT pa."pubId")::int AS count + FROM "PubAttributions" pa + LEFT JOIN "Users" u ON u.id = pa."userId" + WHERE pa."pubId" IN (SELECT id FROM combined) + AND pa."isAuthor" = true + AND (pa.name IS NOT NULL OR u."fullName" IS NOT NULL) + GROUP BY COALESCE(u."fullName", pa.name) + ORDER BY count DESC, name ASC + LIMIT 15 + ) sub + ) + SELECT + id, title, slug, avatar, description, "customPublishedAt", + "communityId", "communityTitle", "communitySlug", "communityDomain", + "communityAvatar", "communityAccentColorDark", "communityAccentColorLight", + "communityHeaderLogo", "communityTextColor", byline, + rank, + count(*) OVER() AS total, + (SELECT facets FROM facet_data) AS "authorFacets" + FROM combined + ORDER BY rank DESC + LIMIT :limit + OFFSET :offset + `; + + try { + const results = await sequelize.query(query, { + replacements: { + tsQuery, + ilikeTerm, + limit, + offset, + ...(communityId ? { communityId } : {}), + ...(author ? { authorFilter: author } : {}), + }, + type: QueryTypes.SELECT, + }); + + const total = results.length > 0 ? Number((results[0] as any).total) : 0; + const authorFacetsJson = results.length > 0 ? (results[0] as any).authorFacets : null; + const facets: { authors: AuthorFacet[] } = { + authors: Array.isArray(authorFacetsJson) ? authorFacetsJson : [], + }; + + return { + results: results.map((r: any) => ({ + id: r.id, + title: r.title, + slug: r.slug, + avatar: r.avatar, + description: r.description, + customPublishedAt: r.customPublishedAt, + communityId: r.communityId, + communityTitle: r.communityTitle, + communitySlug: r.communitySlug, + communityDomain: r.communityDomain, + communityAvatar: r.communityAvatar, + communityAccentColorDark: r.communityAccentColorDark, + communityAccentColorLight: r.communityAccentColorLight, + communityHeaderLogo: r.communityHeaderLogo, + communityTextColor: r.communityTextColor, + byline: r.byline, + rank: r.rank, + })), + total, + facets, + }; + } catch (err) { + console.error('searchPubs full query error, falling back to metadata-only:', err); + return searchPubsSimple(searchTerm, { limit, offset, communityId }); + } +}; + +/** + * Fallback: metadata-only pub search (no doc content). Used if the full + * content query times out or errors. + */ +const searchPubsSimple = async ( + searchTerm: string, + { + limit = 20, + offset = 0, + communityId, + }: { limit?: number; offset?: number; communityId?: string } = {}, +): Promise<{ results: PubSearchResult[]; total: number; facets: { authors: AuthorFacet[] } }> => { + const tsQuery = buildTsQuery(searchTerm); + if (!tsQuery) return { results: [], total: 0, facets: { authors: [] } }; + + const communityFilter = communityId ? `AND p."communityId" = :communityId` : ''; + const ilikeTerm = `%${searchTerm.trim().replace(/[%_]/g, '')}%`; + + const query = ` + WITH latest_release AS ( + SELECT DISTINCT ON (r."pubId") r."pubId" + FROM "Releases" r + ORDER BY r."pubId", r."createdAt" DESC + ), + pub_bylines AS ( + SELECT pa."pubId", + string_agg(COALESCE(u."fullName", pa.name), ', ' ORDER BY pa."order") AS byline + FROM "PubAttributions" pa + LEFT JOIN "Users" u ON u.id = pa."userId" + INNER JOIN latest_release lr2 ON lr2."pubId" = pa."pubId" + WHERE pa."isAuthor" = true + AND (pa.name IS NOT NULL OR u."fullName" IS NOT NULL) + GROUP BY pa."pubId" + ), + matching_pubs AS ( + SELECT + p.id, p.title, p.slug, p.avatar, p.description, p."customPublishedAt", + c.id AS "communityId", + c.title AS "communityTitle", + c.subdomain AS "communitySlug", + c.domain AS "communityDomain", + c.avatar AS "communityAvatar", + c."accentColorDark" AS "communityAccentColorDark", + c."accentColorLight" AS "communityAccentColorLight", + c."headerLogo" AS "communityHeaderLogo", + c."accentTextColor" AS "communityTextColor", + pb.byline, + ts_rank_cd( + setweight(to_tsvector('english', coalesce(p.title, '')), 'A') || + setweight(to_tsvector('english', coalesce(p.description, '')), 'B') || + setweight(to_tsvector('english', coalesce(pb.byline, '')), 'C'), + to_tsquery('english', :tsQuery) + ) AS rank + FROM "Pubs" p + INNER JOIN "Communities" c ON c.id = p."communityId" + INNER JOIN latest_release lr ON lr."pubId" = p.id + LEFT JOIN pub_bylines pb ON pb."pubId" = p.id + LEFT JOIN "SpamTags" st ON st.id = c."spamTagId" + WHERE (st.status IS NULL OR st.status != 'confirmed') + ${communityFilter} + AND ( + ( + setweight(to_tsvector('english', coalesce(p.title, '')), 'A') || + setweight(to_tsvector('english', coalesce(p.description, '')), 'B') || + setweight(to_tsvector('english', coalesce(pb.byline, '')), 'C') + ) @@ to_tsquery('english', :tsQuery) + OR p.title ILIKE :ilikeTerm + ) + ), + facet_data AS ( + SELECT json_agg(sub) AS facets FROM ( + SELECT COALESCE(u."fullName", pa.name) AS name, count(DISTINCT pa."pubId")::int AS count + FROM "PubAttributions" pa + LEFT JOIN "Users" u ON u.id = pa."userId" + WHERE pa."pubId" IN (SELECT id FROM matching_pubs) + AND pa."isAuthor" = true + AND (pa.name IS NOT NULL OR u."fullName" IS NOT NULL) + GROUP BY COALESCE(u."fullName", pa.name) + ORDER BY count DESC, name ASC + LIMIT 15 + ) sub + ) + SELECT + mp.*, + count(*) OVER() AS total, + (SELECT facets FROM facet_data) AS "authorFacets" + FROM matching_pubs mp + ORDER BY rank DESC + LIMIT :limit + OFFSET :offset + `; + + const results = await sequelize.query(query, { + replacements: { + tsQuery, + ilikeTerm, + limit, + offset, + ...(communityId ? { communityId } : {}), + }, + type: QueryTypes.SELECT, + }); + + const total = results.length > 0 ? Number((results[0] as any).total) : 0; + const authorFacetsJson = results.length > 0 ? (results[0] as any).authorFacets : null; + const facets: { authors: AuthorFacet[] } = { + authors: Array.isArray(authorFacetsJson) ? authorFacetsJson : [], + }; + + return { + results: results.map((r: any) => ({ + id: r.id, + title: r.title, + slug: r.slug, + avatar: r.avatar, + description: r.description, + customPublishedAt: r.customPublishedAt, + communityId: r.communityId, + communityTitle: r.communityTitle, + communitySlug: r.communitySlug, + communityDomain: r.communityDomain, + communityAvatar: r.communityAvatar, + communityAccentColorDark: r.communityAccentColorDark, + communityAccentColorLight: r.communityAccentColorLight, + communityHeaderLogo: r.communityHeaderLogo, + communityTextColor: r.communityTextColor, + byline: r.byline, + rank: r.rank, + })), + total, + facets, + }; +}; + +/** + * Search communities using PostgreSQL full-text search. + * Searches over: title, description. + * Excludes confirmed-spam communities. + */ +export const searchCommunities = async ( + searchTerm: string, + { limit = 20, offset = 0 }: { limit?: number; offset?: number } = {}, +): Promise<{ results: CommunitySearchResult[]; total: number }> => { + const tsQuery = buildTsQuery(searchTerm); + if (!tsQuery) return { results: [], total: 0 }; + + const ilikeTerm = `%${searchTerm.trim().replace(/[%_]/g, '')}%`; + + const query = ` + WITH community_pub_counts AS ( + SELECT p."communityId", count(DISTINCT p.id) AS pub_count + FROM "Pubs" p + INNER JOIN "Releases" r ON r."pubId" = p.id + GROUP BY p."communityId" + ) + SELECT + c.id, + c.title, + c.subdomain, + c.domain, + c.description, + c.avatar, + c."accentColorDark", + c."headerLogo", + coalesce(cpc.pub_count, 0)::int AS "pubCount", + ts_rank_cd( + setweight(to_tsvector('english', coalesce(c.title, '')), 'A') || + setweight(to_tsvector('english', coalesce(c.description, '')), 'B'), + to_tsquery('english', :tsQuery) + ) AS rank, + count(*) OVER() AS total + FROM "Communities" c + LEFT JOIN "SpamTags" st ON st.id = c."spamTagId" + LEFT JOIN community_pub_counts cpc ON cpc."communityId" = c.id + WHERE (st.status IS NULL OR st.status != 'confirmed') + AND ( + ( + setweight(to_tsvector('english', coalesce(c.title, '')), 'A') || + setweight(to_tsvector('english', coalesce(c.description, '')), 'B') + ) @@ to_tsquery('english', :tsQuery) + OR c.title ILIKE :ilikeTerm + ) + ORDER BY rank DESC + LIMIT :limit + OFFSET :offset + `; + + const results = await sequelize.query(query, { + replacements: { tsQuery, ilikeTerm, limit, offset }, + type: QueryTypes.SELECT, + }); + + const total = results.length > 0 ? Number((results[0] as any).total) : 0; + + return { + results: results.map((r: any) => ({ + id: r.id, + title: r.title, + subdomain: r.subdomain, + domain: r.domain, + description: r.description, + avatar: r.avatar, + accentColorDark: r.accentColorDark, + headerLogo: r.headerLogo, + pubCount: r.pubCount, + rank: r.rank, + })), + total, + }; +}; diff --git a/server/sequelize.ts b/server/sequelize.ts index 005e1a024e..6107261c4f 100644 --- a/server/sequelize.ts +++ b/server/sequelize.ts @@ -120,5 +120,15 @@ export const knexInstance = knex({ client: 'pg' }); /* Change to true to update the model in the database. */ /* NOTE: This being set to true will erase your data. */ if (process.env.NODE_ENV !== 'test') { - sequelize.sync({ force: false }); + sequelize.sync({ force: false }).then(() => { + // Create GIN tsvector indexes for search — these use expressions that + // can't be declared through Sequelize model definitions. + const ginIndexes = [ + `CREATE INDEX IF NOT EXISTS "pubs_title_tsvector_idx" ON "Pubs" USING gin(to_tsvector('english', coalesce(title, '')))`, + `CREATE INDEX IF NOT EXISTS "pubs_description_tsvector_idx" ON "Pubs" USING gin(to_tsvector('english', coalesce(description, '')))`, + `CREATE INDEX IF NOT EXISTS "communities_title_tsvector_idx" ON "Communities" USING gin(to_tsvector('english', coalesce(title, '')))`, + `CREATE INDEX IF NOT EXISTS "communities_description_tsvector_idx" ON "Communities" USING gin(to_tsvector('english', coalesce(description, '')))`, + ]; + return Promise.allSettled(ginIndexes.map((sql) => sequelize.query(sql))); + }); } From 5e3b926bfc1773c0b4ca22c3e1d9b7fcb0aede24 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Mon, 16 Mar 2026 15:00:18 -0400 Subject: [PATCH 2/4] lint --- client/containers/Search2/Search2.tsx | 15 +++++++++++++-- server/search2/api.ts | 8 ++++---- server/search2/queries.ts | 12 +++++++++--- 3 files changed, 26 insertions(+), 9 deletions(-) diff --git a/client/containers/Search2/Search2.tsx b/client/containers/Search2/Search2.tsx index bf742ed0d6..1b1ac70819 100644 --- a/client/containers/Search2/Search2.tsx +++ b/client/containers/Search2/Search2.tsx @@ -1,6 +1,15 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { Button, Checkbox, Classes, InputGroup, NonIdealState, Spinner, Tab, Tabs } from '@blueprintjs/core'; +import { + Button, + Checkbox, + Classes, + InputGroup, + NonIdealState, + Spinner, + Tab, + Tabs, +} from '@blueprintjs/core'; import { Icon } from 'components'; import { usePageContext, useThrottled } from 'utils/hooks'; @@ -447,7 +456,9 @@ const Search2 = () => { {/* Pagination */} {numPages > 1 && ( -
+
{pages.map((_: any, index: number) => { const key = `page-button-${index}`; return ( diff --git a/server/search2/api.ts b/server/search2/api.ts index 54b2d88cb6..5f9df45b33 100644 --- a/server/search2/api.ts +++ b/server/search2/api.ts @@ -20,14 +20,14 @@ router.get('/api/search2', async (req, res) => { let fields: SearchFields[] | undefined; if (req.query.fields) { const raw = (req.query.fields as string).split(','); - fields = raw.filter((f): f is SearchFields => - ALL_FIELDS.includes(f as SearchFields), - ); + fields = raw.filter((f): f is SearchFields => ALL_FIELDS.includes(f as SearchFields)); if (fields.length === 0) fields = undefined; } if (!q.trim()) { - return res.status(200).json({ results: [], total: 0, page, limit, facets: { authors: [] } }); + return res + .status(200) + .json({ results: [], total: 0, page, limit, facets: { authors: [] } }); } if (mode === 'communities') { diff --git a/server/search2/queries.ts b/server/search2/queries.ts index b06122aec5..0c2dd8a8a5 100644 --- a/server/search2/queries.ts +++ b/server/search2/queries.ts @@ -85,7 +85,9 @@ export const searchPubs = async ( const searchContent = searchFields.includes('content'); const communityFilter = communityId ? `AND p."communityId" = :communityId` : ''; - const authorFilter = author ? `AND EXISTS (SELECT 1 FROM "PubAttributions" af LEFT JOIN "Users" au ON au.id = af."userId" WHERE af."pubId" = p.id AND af."isAuthor" = true AND (af.name ILIKE :authorFilter OR au."fullName" ILIKE :authorFilter))` : ''; + const authorFilter = author + ? `AND EXISTS (SELECT 1 FROM "PubAttributions" af LEFT JOIN "Users" au ON au.id = af."userId" WHERE af."pubId" = p.id AND af."isAuthor" = true AND (af.name ILIKE :authorFilter OR au."fullName" ILIKE :authorFilter))` + : ''; const ilikeTerm = `%${searchTerm.trim().replace(/[%_]/g, '')}%`; // Two-phase approach: @@ -155,7 +157,9 @@ export const searchPubs = async ( ) @@ to_tsquery('english', :tsQuery) ${searchTitle ? `OR rp.title ILIKE :ilikeTerm` : ''} ), - ${searchContent ? ` + ${ + searchContent + ? ` -- Phase 2: full-text content search (only for pubs not already matched) content_matches AS ( SELECT @@ -184,7 +188,9 @@ export const searchPubs = async ( WHERE rp.id NOT IN (SELECT mm.id FROM metadata_matches mm) AND to_tsvector('english', coalesce(doc_plain.plain_text, '')) @@ to_tsquery('english', :tsQuery) - ),` : ''} + ),` + : '' + } -- Combine both phases combined AS ( SELECT * FROM metadata_matches From 39689bbbc2b87f88df6264afc13907e2857edc51 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Tue, 17 Mar 2026 12:49:55 -0400 Subject: [PATCH 3/4] Test new search approach --- server/community/model.ts | 14 +- server/pub/model.ts | 15 +- server/search2/queries.ts | 305 +++++++------------------------ server/search2/searchTriggers.ts | 238 ++++++++++++++++++++++++ server/sequelize.ts | 20 +- 5 files changed, 337 insertions(+), 255 deletions(-) create mode 100644 server/search2/searchTriggers.ts diff --git a/server/community/model.ts b/server/community/model.ts index 1728cffcb5..8258896955 100644 --- a/server/community/model.ts +++ b/server/community/model.ts @@ -36,9 +36,11 @@ import { } from '../models'; @Table +// GIN index on searchVector is created in searchTriggers.ts +// after the column is added via ALTER TABLE. export class Community extends Model< - InferAttributes, - InferCreationAttributes + InferAttributes, + InferCreationAttributes > { public declare toJSON: (this: M) => SerializedModel; @@ -227,6 +229,14 @@ export class Community extends Model< @Column(DataType.UUID) declare spamTagId: string | null; + /** + * Pre-computed tsvector for full-text search. + * Weights: A=title, B=description. + * Updated by Postgres trigger on Communities. + */ + @Column(DataType.TSVECTOR) + declare searchVector: any | null; + @Column(DataType.UUID) declare organizationId: string | null; diff --git a/server/pub/model.ts b/server/pub/model.ts index 74186d8e2e..f5fe69eebb 100644 --- a/server/pub/model.ts +++ b/server/pub/model.ts @@ -44,9 +44,14 @@ import { unique: true, fields: ['communityId', 'slug'], }, + // GIN index on searchVector is created in searchTriggers.ts + // after the column is added via ALTER TABLE. ], }) -export class Pub extends Model, InferCreationAttributes> { +export class Pub extends Model< + InferAttributes, + InferCreationAttributes +> { public declare toJSON: (this: M) => SerializedModel; @Default(DataType.UUIDV4) @@ -135,6 +140,14 @@ export class Pub extends Model, InferCreationAttributes PubAttribution, { onDelete: 'CASCADE', as: 'attributions', foreignKey: 'pubId' }) declare attributions?: PubAttribution[]; diff --git a/server/search2/queries.ts b/server/search2/queries.ts index 0c2dd8a8a5..150aec923d 100644 --- a/server/search2/queries.ts +++ b/server/search2/queries.ts @@ -51,12 +51,39 @@ const buildTsQuery = (searchTerm: string): string | null => { }; /** - * Search pubs using PostgreSQL full-text search. + * Map user-selected field checkboxes to tsvector weight letters. * - * Strategy: First find matching pubs via title/description/byline (fast), - * then optionally search release doc content for additional matches. - * The two result sets are unioned and ranked together. + * title -> A + * description -> B + * byline -> C + * content -> D * + * When all weights are selected we skip the ts_rank weight mask and just use + * the full searchVector. When a subset is selected we filter the tsquery with + * those weights so only matching lexemes in those sections contribute. + */ +const FIELD_WEIGHTS: Record = { + title: 'A', + description: 'B', + byline: 'C', + content: 'D', +}; + +const ALL_WEIGHT_LETTERS = 'ABCD'; + +const getWeightMask = (fields?: SearchFields[]): string => { + if (!fields || fields.length === 0) return 'ABC'; // default: no content + const weights = fields.map((f) => FIELD_WEIGHTS[f]).join(''); + return weights || 'ABC'; +}; + +/** + * Search pubs using the pre-computed searchVector column (GIN-indexed). + * + * The searchVector is maintained by Postgres triggers and contains weighted + * tsvector data: A=title, B=description, C=byline, D=doc content. + * + * Field selection works by restricting the tsquery to specific weights. * Only returns publicly released pubs. Excludes spam communities. */ export const searchPubs = async ( @@ -78,229 +105,33 @@ export const searchPubs = async ( const tsQuery = buildTsQuery(searchTerm); if (!tsQuery) return { results: [], total: 0, facets: { authors: [] } }; - const searchFields = fields && fields.length > 0 ? fields : ['title', 'description', 'byline']; - const searchTitle = searchFields.includes('title'); - const searchDescription = searchFields.includes('description'); - const searchByline = searchFields.includes('byline'); - const searchContent = searchFields.includes('content'); + const weightMask = getWeightMask(fields); + const useWeightFilter = weightMask !== ALL_WEIGHT_LETTERS; const communityFilter = communityId ? `AND p."communityId" = :communityId` : ''; const authorFilter = author - ? `AND EXISTS (SELECT 1 FROM "PubAttributions" af LEFT JOIN "Users" au ON au.id = af."userId" WHERE af."pubId" = p.id AND af."isAuthor" = true AND (af.name ILIKE :authorFilter OR au."fullName" ILIKE :authorFilter))` + ? `AND EXISTS ( + SELECT 1 FROM "PubAttributions" af + LEFT JOIN "Users" au ON au.id = af."userId" + WHERE af."pubId" = p.id AND af."isAuthor" = true + AND (af.name ILIKE :authorFilter OR au."fullName" ILIKE :authorFilter) + )` : ''; const ilikeTerm = `%${searchTerm.trim().replace(/[%_]/g, '')}%`; - // Two-phase approach: - // Phase 1 (metadata): Search title, description, byline with tsvector + ILIKE fallback - // Phase 2 (content): Search doc text extracted from latest release — uses a - // targeted LATERAL extraction only on candidate docs, not all Docs rows. - // We UNION these and deduplicate, taking the max rank. - const query = ` - WITH latest_release AS ( - SELECT DISTINCT ON (r."pubId") r."pubId", r."docId" - FROM "Releases" r - ORDER BY r."pubId", r."createdAt" DESC - ), - pub_bylines AS ( - SELECT pa."pubId", - string_agg(COALESCE(u."fullName", pa.name), ', ' ORDER BY pa."order") AS byline - FROM "PubAttributions" pa - LEFT JOIN "Users" u ON u.id = pa."userId" - INNER JOIN latest_release lr2 ON lr2."pubId" = pa."pubId" - WHERE pa."isAuthor" = true - AND (pa.name IS NOT NULL OR u."fullName" IS NOT NULL) - GROUP BY pa."pubId" - ), - released_pubs AS ( - SELECT - p.id, - p.title, - p.slug, - p.avatar, - p.description, - p."customPublishedAt", - c.id AS "communityId", - c.title AS "communityTitle", - c.subdomain AS "communitySlug", - c.domain AS "communityDomain", - c.avatar AS "communityAvatar", - c."accentColorDark" AS "communityAccentColorDark", - c."accentColorLight" AS "communityAccentColorLight", - c."headerLogo" AS "communityHeaderLogo", - c."accentTextColor" AS "communityTextColor", - pb.byline, - lr."docId" - FROM "Pubs" p - INNER JOIN "Communities" c ON c.id = p."communityId" - INNER JOIN latest_release lr ON lr."pubId" = p.id - LEFT JOIN pub_bylines pb ON pb."pubId" = p.id - LEFT JOIN "SpamTags" st ON st.id = c."spamTagId" - WHERE (st.status IS NULL OR st.status != 'confirmed') - ${communityFilter} - ${authorFilter} - ), - -- Phase 1: metadata search (title, description, byline) - metadata_matches AS ( - SELECT - rp.*, - ts_rank_cd( - ${searchTitle ? `setweight(to_tsvector('english', coalesce(rp.title, '')), 'A')` : `to_tsvector('')`} || - ${searchDescription ? `setweight(to_tsvector('english', coalesce(rp.description, '')), 'B')` : `to_tsvector('')`} || - ${searchByline ? `setweight(to_tsvector('english', coalesce(rp.byline, '')), 'C')` : `to_tsvector('')`}, - to_tsquery('english', :tsQuery) - ) AS rank - FROM released_pubs rp - WHERE ( - ${searchTitle ? `setweight(to_tsvector('english', coalesce(rp.title, '')), 'A')` : `to_tsvector('')`} || - ${searchDescription ? `setweight(to_tsvector('english', coalesce(rp.description, '')), 'B')` : `to_tsvector('')`} || - ${searchByline ? `setweight(to_tsvector('english', coalesce(rp.byline, '')), 'C')` : `to_tsvector('')`} - ) @@ to_tsquery('english', :tsQuery) - ${searchTitle ? `OR rp.title ILIKE :ilikeTerm` : ''} - ), - ${ - searchContent - ? ` - -- Phase 2: full-text content search (only for pubs not already matched) - content_matches AS ( - SELECT - rp.*, - ts_rank_cd( - to_tsvector('english', coalesce(doc_plain.plain_text, '')), - to_tsquery('english', :tsQuery) - ) * 0.5 AS rank - FROM released_pubs rp - INNER JOIN LATERAL ( - SELECT string_agg(elem.txt, ' ') AS plain_text - FROM ( - WITH RECURSIVE nodes(node) AS ( - SELECT d.content FROM "Docs" d WHERE d.id = rp."docId" - UNION ALL - SELECT jsonb_array_elements(nodes.node->'content') - FROM nodes - WHERE nodes.node->'content' IS NOT NULL - AND jsonb_typeof(nodes.node->'content') = 'array' - ) - SELECT node->>'text' AS txt - FROM nodes - WHERE node->>'text' IS NOT NULL - ) elem - ) doc_plain ON true - WHERE rp.id NOT IN (SELECT mm.id FROM metadata_matches mm) - AND to_tsvector('english', coalesce(doc_plain.plain_text, '')) - @@ to_tsquery('english', :tsQuery) - ),` - : '' - } - -- Combine both phases - combined AS ( - SELECT * FROM metadata_matches - ${searchContent ? `UNION ALL SELECT * FROM content_matches` : ''} - ), - facet_data AS ( - SELECT json_agg(sub) AS facets FROM ( - SELECT COALESCE(u."fullName", pa.name) AS name, count(DISTINCT pa."pubId")::int AS count - FROM "PubAttributions" pa - LEFT JOIN "Users" u ON u.id = pa."userId" - WHERE pa."pubId" IN (SELECT id FROM combined) - AND pa."isAuthor" = true - AND (pa.name IS NOT NULL OR u."fullName" IS NOT NULL) - GROUP BY COALESCE(u."fullName", pa.name) - ORDER BY count DESC, name ASC - LIMIT 15 - ) sub - ) - SELECT - id, title, slug, avatar, description, "customPublishedAt", - "communityId", "communityTitle", "communitySlug", "communityDomain", - "communityAvatar", "communityAccentColorDark", "communityAccentColorLight", - "communityHeaderLogo", "communityTextColor", byline, - rank, - count(*) OVER() AS total, - (SELECT facets FROM facet_data) AS "authorFacets" - FROM combined - ORDER BY rank DESC - LIMIT :limit - OFFSET :offset - `; - - try { - const results = await sequelize.query(query, { - replacements: { - tsQuery, - ilikeTerm, - limit, - offset, - ...(communityId ? { communityId } : {}), - ...(author ? { authorFilter: author } : {}), - }, - type: QueryTypes.SELECT, - }); - - const total = results.length > 0 ? Number((results[0] as any).total) : 0; - const authorFacetsJson = results.length > 0 ? (results[0] as any).authorFacets : null; - const facets: { authors: AuthorFacet[] } = { - authors: Array.isArray(authorFacetsJson) ? authorFacetsJson : [], - }; - - return { - results: results.map((r: any) => ({ - id: r.id, - title: r.title, - slug: r.slug, - avatar: r.avatar, - description: r.description, - customPublishedAt: r.customPublishedAt, - communityId: r.communityId, - communityTitle: r.communityTitle, - communitySlug: r.communitySlug, - communityDomain: r.communityDomain, - communityAvatar: r.communityAvatar, - communityAccentColorDark: r.communityAccentColorDark, - communityAccentColorLight: r.communityAccentColorLight, - communityHeaderLogo: r.communityHeaderLogo, - communityTextColor: r.communityTextColor, - byline: r.byline, - rank: r.rank, - })), - total, - facets, - }; - } catch (err) { - console.error('searchPubs full query error, falling back to metadata-only:', err); - return searchPubsSimple(searchTerm, { limit, offset, communityId }); - } -}; - -/** - * Fallback: metadata-only pub search (no doc content). Used if the full - * content query times out or errors. - */ -const searchPubsSimple = async ( - searchTerm: string, - { - limit = 20, - offset = 0, - communityId, - }: { limit?: number; offset?: number; communityId?: string } = {}, -): Promise<{ results: PubSearchResult[]; total: number; facets: { authors: AuthorFacet[] } }> => { - const tsQuery = buildTsQuery(searchTerm); - if (!tsQuery) return { results: [], total: 0, facets: { authors: [] } }; - - const communityFilter = communityId ? `AND p."communityId" = :communityId` : ''; - const ilikeTerm = `%${searchTerm.trim().replace(/[%_]/g, '')}%`; + // When weight filtering is active, we strip the tsvector to only the + // selected weights before matching and ranking. Otherwise we use the + // full searchVector which is entirely covered by the GIN index. + const vectorExpr = useWeightFilter + ? `ts_filter(p."searchVector", '{${weightMask.split('').join(',')}}')` + : `p."searchVector"`; const query = ` - WITH latest_release AS ( - SELECT DISTINCT ON (r."pubId") r."pubId" - FROM "Releases" r - ORDER BY r."pubId", r."createdAt" DESC - ), - pub_bylines AS ( + WITH pub_bylines AS ( SELECT pa."pubId", string_agg(COALESCE(u."fullName", pa.name), ', ' ORDER BY pa."order") AS byline FROM "PubAttributions" pa LEFT JOIN "Users" u ON u.id = pa."userId" - INNER JOIN latest_release lr2 ON lr2."pubId" = pa."pubId" WHERE pa."isAuthor" = true AND (pa.name IS NOT NULL OR u."fullName" IS NOT NULL) GROUP BY pa."pubId" @@ -318,34 +149,30 @@ const searchPubsSimple = async ( c."headerLogo" AS "communityHeaderLogo", c."accentTextColor" AS "communityTextColor", pb.byline, - ts_rank_cd( - setweight(to_tsvector('english', coalesce(p.title, '')), 'A') || - setweight(to_tsvector('english', coalesce(p.description, '')), 'B') || - setweight(to_tsvector('english', coalesce(pb.byline, '')), 'C'), - to_tsquery('english', :tsQuery) - ) AS rank + ts_rank_cd(${vectorExpr}, to_tsquery('english', :tsQuery)) AS rank FROM "Pubs" p INNER JOIN "Communities" c ON c.id = p."communityId" - INNER JOIN latest_release lr ON lr."pubId" = p.id + INNER JOIN "Releases" r_exists ON r_exists."pubId" = p.id LEFT JOIN pub_bylines pb ON pb."pubId" = p.id LEFT JOIN "SpamTags" st ON st.id = c."spamTagId" WHERE (st.status IS NULL OR st.status != 'confirmed') - ${communityFilter} + AND p."searchVector" IS NOT NULL AND ( - ( - setweight(to_tsvector('english', coalesce(p.title, '')), 'A') || - setweight(to_tsvector('english', coalesce(p.description, '')), 'B') || - setweight(to_tsvector('english', coalesce(pb.byline, '')), 'C') - ) @@ to_tsquery('english', :tsQuery) + ${vectorExpr} @@ to_tsquery('english', :tsQuery) OR p.title ILIKE :ilikeTerm ) + ${communityFilter} + ${authorFilter} + ), + deduped AS ( + SELECT DISTINCT ON (id) * FROM matching_pubs ), facet_data AS ( SELECT json_agg(sub) AS facets FROM ( SELECT COALESCE(u."fullName", pa.name) AS name, count(DISTINCT pa."pubId")::int AS count FROM "PubAttributions" pa LEFT JOIN "Users" u ON u.id = pa."userId" - WHERE pa."pubId" IN (SELECT id FROM matching_pubs) + WHERE pa."pubId" IN (SELECT id FROM deduped) AND pa."isAuthor" = true AND (pa.name IS NOT NULL OR u."fullName" IS NOT NULL) GROUP BY COALESCE(u."fullName", pa.name) @@ -354,10 +181,10 @@ const searchPubsSimple = async ( ) sub ) SELECT - mp.*, + d.*, count(*) OVER() AS total, (SELECT facets FROM facet_data) AS "authorFacets" - FROM matching_pubs mp + FROM deduped d ORDER BY rank DESC LIMIT :limit OFFSET :offset @@ -370,6 +197,7 @@ const searchPubsSimple = async ( limit, offset, ...(communityId ? { communityId } : {}), + ...(author ? { authorFilter: author } : {}), }, type: QueryTypes.SELECT, }); @@ -406,8 +234,7 @@ const searchPubsSimple = async ( }; /** - * Search communities using PostgreSQL full-text search. - * Searches over: title, description. + * Search communities using the pre-computed searchVector column (GIN-indexed). * Excludes confirmed-spam communities. */ export const searchCommunities = async ( @@ -436,21 +263,15 @@ export const searchCommunities = async ( c."accentColorDark", c."headerLogo", coalesce(cpc.pub_count, 0)::int AS "pubCount", - ts_rank_cd( - setweight(to_tsvector('english', coalesce(c.title, '')), 'A') || - setweight(to_tsvector('english', coalesce(c.description, '')), 'B'), - to_tsquery('english', :tsQuery) - ) AS rank, + ts_rank_cd(c."searchVector", to_tsquery('english', :tsQuery)) AS rank, count(*) OVER() AS total FROM "Communities" c LEFT JOIN "SpamTags" st ON st.id = c."spamTagId" LEFT JOIN community_pub_counts cpc ON cpc."communityId" = c.id WHERE (st.status IS NULL OR st.status != 'confirmed') + AND c."searchVector" IS NOT NULL AND ( - ( - setweight(to_tsvector('english', coalesce(c.title, '')), 'A') || - setweight(to_tsvector('english', coalesce(c.description, '')), 'B') - ) @@ to_tsquery('english', :tsQuery) + c."searchVector" @@ to_tsquery('english', :tsQuery) OR c.title ILIKE :ilikeTerm ) ORDER BY rank DESC diff --git a/server/search2/searchTriggers.ts b/server/search2/searchTriggers.ts new file mode 100644 index 0000000000..3a34b4ac1e --- /dev/null +++ b/server/search2/searchTriggers.ts @@ -0,0 +1,238 @@ +/** + * Postgres triggers and functions for maintaining pre-computed tsvector columns + * on the Pubs and Communities tables. These are installed after sequelize.sync() + * so the columns/indexes exist before the triggers reference them. + * + * Weight mapping for Pubs: + * A = title + * B = description + * C = byline (aggregated from PubAttributions + Users) + * D = latest release doc content (extracted from ProseMirror JSON) + * + * Weight mapping for Communities: + * A = title + * B = description + * + * Triggers fire on: + * - Pubs INSERT/UPDATE of title or description + * - PubAttributions INSERT/UPDATE/DELETE (recalculates byline for affected pub) + * - Releases INSERT (new release = new doc content for the pub) + * - Communities INSERT/UPDATE of title or description + */ + +import { sequelize } from 'server/sequelize'; + +/** + * Install all search-related triggers and functions. Idempotent — uses + * CREATE OR REPLACE and IF NOT EXISTS throughout. + */ +export const installSearchTriggers = async () => { + // ---- Ensure searchVector columns exist (sync force:false won't add them) ---- + await sequelize.query(` + ALTER TABLE "Pubs" ADD COLUMN IF NOT EXISTS "searchVector" tsvector; + `); + await sequelize.query(` + ALTER TABLE "Communities" ADD COLUMN IF NOT EXISTS "searchVector" tsvector; + `); + + // ---- GIN indexes on searchVector columns ---- + await sequelize.query(` + CREATE INDEX IF NOT EXISTS pubs_search_vector_idx + ON "Pubs" USING gin ("searchVector"); + `); + await sequelize.query(` + CREATE INDEX IF NOT EXISTS communities_search_vector_idx + ON "Communities" USING gin ("searchVector"); + `); + + // ---- Helper: extract plain text from ProseMirror JSONB ---- + await sequelize.query(` + CREATE OR REPLACE FUNCTION extract_doc_text(doc_content jsonb) + RETURNS text + LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$ + WITH RECURSIVE nodes(node) AS ( + SELECT doc_content + UNION ALL + SELECT jsonb_array_elements(nodes.node->'content') + FROM nodes + WHERE nodes.node->'content' IS NOT NULL + AND jsonb_typeof(nodes.node->'content') = 'array' + ) + SELECT coalesce(string_agg(node->>'text', ' '), '') + FROM nodes + WHERE node->>'text' IS NOT NULL; + $$; + `); + + // ---- Pub search vector update function ---- + await sequelize.query(` + CREATE OR REPLACE FUNCTION pub_search_vector_update() + RETURNS trigger + LANGUAGE plpgsql AS $$ + DECLARE + pub_row RECORD; + byline_text text; + doc_text text; + doc_id uuid; + BEGIN + -- Determine which pub to update + IF TG_TABLE_NAME = 'Pubs' THEN + pub_row := NEW; + ELSIF TG_TABLE_NAME = 'PubAttributions' THEN + IF TG_OP = 'DELETE' THEN + pub_row := OLD; + ELSE + pub_row := NEW; + END IF; + ELSIF TG_TABLE_NAME = 'Releases' THEN + pub_row := NEW; + END IF; + + -- Get the full pub title/description (needed when triggered from related tables) + IF TG_TABLE_NAME != 'Pubs' THEN + SELECT INTO pub_row p.* FROM "Pubs" p WHERE p.id = pub_row."pubId"; + IF NOT FOUND THEN + RETURN COALESCE(NEW, OLD); + END IF; + END IF; + + -- Aggregate byline from PubAttributions + Users + SELECT coalesce(string_agg(coalesce(u."fullName", pa.name), ' '), '') + INTO byline_text + FROM "PubAttributions" pa + LEFT JOIN "Users" u ON u.id = pa."userId" + WHERE pa."pubId" = pub_row.id + AND pa."isAuthor" = true + AND (pa.name IS NOT NULL OR u."fullName" IS NOT NULL); + + -- Get latest release doc content + SELECT r."docId" INTO doc_id + FROM "Releases" r + WHERE r."pubId" = pub_row.id + ORDER BY r."createdAt" DESC + LIMIT 1; + + IF doc_id IS NOT NULL THEN + SELECT extract_doc_text(d.content) INTO doc_text + FROM "Docs" d WHERE d.id = doc_id; + END IF; + + -- Update the search vector + UPDATE "Pubs" SET "searchVector" = + setweight(to_tsvector('english', coalesce(pub_row.title, '')), 'A') || + setweight(to_tsvector('english', coalesce(pub_row.description, '')), 'B') || + setweight(to_tsvector('english', coalesce(byline_text, '')), 'C') || + setweight(to_tsvector('english', coalesce(doc_text, '')), 'D') + WHERE id = pub_row.id; + + RETURN COALESCE(NEW, OLD); + END; + $$; + `); + + // ---- Triggers on Pubs ---- + await sequelize.query(` + DROP TRIGGER IF EXISTS pubs_search_vector_update ON "Pubs"; + CREATE TRIGGER pubs_search_vector_update + AFTER INSERT OR UPDATE OF title, description ON "Pubs" + FOR EACH ROW + EXECUTE FUNCTION pub_search_vector_update(); + `); + + // ---- Triggers on PubAttributions ---- + await sequelize.query(` + DROP TRIGGER IF EXISTS pubattributions_search_vector_update ON "PubAttributions"; + CREATE TRIGGER pubattributions_search_vector_update + AFTER INSERT OR UPDATE OR DELETE ON "PubAttributions" + FOR EACH ROW + EXECUTE FUNCTION pub_search_vector_update(); + `); + + // ---- Triggers on Releases ---- + await sequelize.query(` + DROP TRIGGER IF EXISTS releases_search_vector_update ON "Releases"; + CREATE TRIGGER releases_search_vector_update + AFTER INSERT ON "Releases" + FOR EACH ROW + EXECUTE FUNCTION pub_search_vector_update(); + `); + + // ---- Community search vector update function ---- + await sequelize.query(` + CREATE OR REPLACE FUNCTION community_search_vector_update() + RETURNS trigger + LANGUAGE plpgsql AS $$ + BEGIN + NEW."searchVector" := + setweight(to_tsvector('english', coalesce(NEW.title, '')), 'A') || + setweight(to_tsvector('english', coalesce(NEW.description, '')), 'B'); + RETURN NEW; + END; + $$; + `); + + // ---- Trigger on Communities (BEFORE so we can modify NEW directly) ---- + await sequelize.query(` + DROP TRIGGER IF EXISTS communities_search_vector_update ON "Communities"; + CREATE TRIGGER communities_search_vector_update + BEFORE INSERT OR UPDATE OF title, description ON "Communities" + FOR EACH ROW + EXECUTE FUNCTION community_search_vector_update(); + `); +}; + +/** + * Backfill searchVector for all existing Pubs. Run once after adding the column. + * Uses batched updates to avoid locking the whole table. + */ +export const backfillPubSearchVectors = async () => { + // Step 1: Set title + description for ALL pubs that haven't been backfilled + await sequelize.query(` + UPDATE "Pubs" SET "searchVector" = + setweight(to_tsvector('english', coalesce(title, '')), 'A') || + setweight(to_tsvector('english', coalesce(description, '')), 'B') + WHERE "searchVector" IS NULL; + `); + + // Step 2: Layer in byline (weight C) from PubAttributions + await sequelize.query(` + UPDATE "Pubs" p SET "searchVector" = p."searchVector" || + setweight(to_tsvector('english', byline_sub.byline_text), 'C') + FROM ( + SELECT pa."pubId", + string_agg(coalesce(u."fullName", pa.name), ' ') AS byline_text + FROM "PubAttributions" pa + LEFT JOIN "Users" u ON u.id = pa."userId" + WHERE pa."isAuthor" = true + AND (pa.name IS NOT NULL OR u."fullName" IS NOT NULL) + GROUP BY pa."pubId" + ) byline_sub + WHERE p.id = byline_sub."pubId"; + `); + + // Step 3: Layer in latest release doc content (weight D) + await sequelize.query(` + UPDATE "Pubs" p SET "searchVector" = p."searchVector" || + setweight(to_tsvector('english', doc_sub.doc_text), 'D') + FROM ( + SELECT DISTINCT ON (r."pubId") r."pubId", + extract_doc_text(d.content) AS doc_text + FROM "Releases" r + JOIN "Docs" d ON d.id = r."docId" + ORDER BY r."pubId", r."createdAt" DESC + ) doc_sub + WHERE p.id = doc_sub."pubId"; + `); +}; + +/** + * Backfill searchVector for all existing Communities. + */ +export const backfillCommunitySearchVectors = async () => { + await sequelize.query(` + UPDATE "Communities" SET "searchVector" = + setweight(to_tsvector('english', coalesce(title, '')), 'A') || + setweight(to_tsvector('english', coalesce(description, '')), 'B') + WHERE "searchVector" IS NULL; + `); +}; diff --git a/server/sequelize.ts b/server/sequelize.ts index 6107261c4f..ad86ee1c55 100644 --- a/server/sequelize.ts +++ b/server/sequelize.ts @@ -120,15 +120,15 @@ export const knexInstance = knex({ client: 'pg' }); /* Change to true to update the model in the database. */ /* NOTE: This being set to true will erase your data. */ if (process.env.NODE_ENV !== 'test') { - sequelize.sync({ force: false }).then(() => { - // Create GIN tsvector indexes for search — these use expressions that - // can't be declared through Sequelize model definitions. - const ginIndexes = [ - `CREATE INDEX IF NOT EXISTS "pubs_title_tsvector_idx" ON "Pubs" USING gin(to_tsvector('english', coalesce(title, '')))`, - `CREATE INDEX IF NOT EXISTS "pubs_description_tsvector_idx" ON "Pubs" USING gin(to_tsvector('english', coalesce(description, '')))`, - `CREATE INDEX IF NOT EXISTS "communities_title_tsvector_idx" ON "Communities" USING gin(to_tsvector('english', coalesce(title, '')))`, - `CREATE INDEX IF NOT EXISTS "communities_description_tsvector_idx" ON "Communities" USING gin(to_tsvector('english', coalesce(description, '')))`, - ]; - return Promise.allSettled(ginIndexes.map((sql) => sequelize.query(sql))); + sequelize.sync({ force: false }).then(async () => { + // Install search triggers and backfill tsvector columns + const { + installSearchTriggers, + backfillPubSearchVectors, + backfillCommunitySearchVectors, + } = await import('server/search2/searchTriggers'); + await installSearchTriggers(); + await backfillPubSearchVectors(); + await backfillCommunitySearchVectors(); }); } From 4e2d51acdc2e137878f07a08c8828819f4c2cc39 Mon Sep 17 00:00:00 2001 From: Travis Rich Date: Tue, 17 Mar 2026 12:50:18 -0400 Subject: [PATCH 4/4] lint --- server/sequelize.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/server/sequelize.ts b/server/sequelize.ts index ad86ee1c55..5385ee2585 100644 --- a/server/sequelize.ts +++ b/server/sequelize.ts @@ -122,11 +122,8 @@ export const knexInstance = knex({ client: 'pg' }); if (process.env.NODE_ENV !== 'test') { sequelize.sync({ force: false }).then(async () => { // Install search triggers and backfill tsvector columns - const { - installSearchTriggers, - backfillPubSearchVectors, - backfillCommunitySearchVectors, - } = await import('server/search2/searchTriggers'); + const { installSearchTriggers, backfillPubSearchVectors, backfillCommunitySearchVectors } = + await import('server/search2/searchTriggers'); await installSearchTriggers(); await backfillPubSearchVectors(); await backfillCommunitySearchVectors();