feat(api): unified FTS search
Single GET /api/search?q=&space_id=&kinds=&limit=&offset= unions FTS hits across pages / refs / source_docs / messages with a `kind` discriminator and ts_rank ordering. Each branch's to_tsvector matches the GIN index expression on its source table so indexes are used. Messages have no space_id and are excluded when a space filter is set. Hybrid vector / RRF lands in Plan 3. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,7 @@ import { router as tagsRouter, entityScopedRouter as tagsByEntityRouter } from '
|
||||
import { router as linksRouter } from './routes/links.js';
|
||||
import { router as pendingChangesRouter } from './routes/pending_changes.js';
|
||||
import { router as auditRouter } from './routes/audit.js';
|
||||
import { router as searchRouter } from './routes/search.js';
|
||||
|
||||
export function mountApi(app) {
|
||||
const api = Router();
|
||||
@@ -45,6 +46,7 @@ export function mountApi(app) {
|
||||
api.use('/links', linksRouter);
|
||||
api.use('/pending-changes', pendingChangesRouter);
|
||||
api.use('/audit', auditRouter);
|
||||
api.use('/search', searchRouter);
|
||||
api.use('/:entity_type/:entity_id/tags', tagsByEntityRouter);
|
||||
|
||||
api.use((_req, _res, next) => next(new NotFoundError('route not found')));
|
||||
|
||||
37
lib/api/routes/search.js
Normal file
37
lib/api/routes/search.js
Normal file
@@ -0,0 +1,37 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
import * as repo from '../../db/repos/search.js';
|
||||
import { parsePagination } from '../pagination.js';
|
||||
import { validate } from '../validate.js';
|
||||
import { asyncWrap } from '../errors.js';
|
||||
|
||||
const KINDS = ['page','ref','source_doc','message'];
|
||||
|
||||
const querySchema = z.object({
|
||||
q: z.string().min(1),
|
||||
space_id: z.string().uuid().optional(),
|
||||
kinds: z.string().optional(),
|
||||
limit: z.string().optional(),
|
||||
offset: z.string().optional()
|
||||
});
|
||||
|
||||
export const router = Router();
|
||||
|
||||
// Hybrid vector + reciprocal-rank-fusion search lands in Plan 3 (see
|
||||
// docs/superpowers/specs/2026-05-31-void-v2-design.md §search).
|
||||
router.get('/',
|
||||
validate({ query: querySchema }),
|
||||
asyncWrap(async (req, res) => {
|
||||
const { limit, offset } = parsePagination(req);
|
||||
const rawKinds = req.validatedQuery.kinds;
|
||||
const kinds = rawKinds
|
||||
? rawKinds.split(',').map(s => s.trim()).filter(k => KINDS.includes(k))
|
||||
: null;
|
||||
res.json(await repo.fts({
|
||||
q: req.validatedQuery.q,
|
||||
space_id: req.validatedQuery.space_id ?? null,
|
||||
kinds,
|
||||
limit, offset
|
||||
}));
|
||||
})
|
||||
);
|
||||
Reference in New Issue
Block a user