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:
root
2026-06-01 02:04:57 +10:00
parent ec96e4e2e3
commit 69e26ada98
5 changed files with 289 additions and 0 deletions

37
lib/api/routes/search.js Normal file
View 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
}));
})
);