// Package index is the (Phase 1) full-text search sync layer for archivdms. // // PostgreSQL remains the single source of truth; this package keeps a // secondary, per-tenant Manticore Search index (Hybrid BM25+Vektor is a later // phase) in sync with the documents table. Only the write/sync half is // implemented here — there is deliberately NO search endpoint yet (Phase 2/3). // // Design guarantees: // - The index is best-effort. When Manticore is not configured (empty DSN) // the whole thing degrades to a no-op: the Indexer is nil and every caller // skips silently. // - An index error must NEVER be propagated to the originating HTTP request. // Callers log and move on. Postgres stays authoritative, so a stale index // is a recoverable, non-fatal condition (a later reindex CLI, Phase 3, // rebuilds it). // // This package intentionally has NO dependency on internal/storage to avoid an // import cycle: storage builds DocumentDoc values and calls into here. package index import ( "context" "time" ) // DocumentDoc is the index representation of a stored document. It is the // projection of a documents row plus its resolved taxonomy (tags) and ACL // (visibility group IDs) that the search index needs. type DocumentDoc struct { ID int64 TenantID int64 Title string DocType string // deprecated free-text doc_type Correspondent string // deprecated free-text correspondent OCRText string Tags []string TagIDs []int64 DocTypeID *int64 CorrespondentID *int64 ACLGroupIDs []int64 RetainUntil *time.Time CreatedAt time.Time UpdatedAt time.Time } // SearchQuery is the (Phase 3) full-text + attribute query against a single // tenant's index. It intentionally carries only what the index needs to return // a ranked list of documents.id values; the caller re-hydrates the full // document rows from Postgres (the source of truth) afterwards. type SearchQuery struct { // Query is the raw user full-text term. It is escaped before it ever // reaches a MATCH() expression — callers pass it verbatim. Query string // TagIDs, when non-empty, restricts hits to documents carrying ANY of // these tag ids (MVA filter). TagIDs []int64 // DocTypeID, when non-nil, restricts hits to that document type. DocTypeID *int64 // ACLGroupIDs applies the group-resolved document ACL: when non-nil, only // documents visible to ANY of these permission groups are returned. A nil // slice means "no ACL filter" (domain_admin/superadmin). An explicitly // empty (non-nil) slice would match nothing — callers must short-circuit // that case before querying. ACLGroupIDs []int64 // Page is 1-based; PageSize caps hits per page. Page int PageSize int } // SearchHit is a single ranked result: a documents.id plus its BM25 score. type SearchHit struct { ID int64 Score float64 } // Indexer syncs a single (tenant-scoped) document index. Implementations must // never block or fail the calling request on transient backend errors beyond // returning the error for the caller to log. type Indexer interface { // IndexSync inserts or replaces the document (id-based upsert). IndexSync(ctx context.Context, doc DocumentDoc) error // Delete removes the document from the index by its documents.id. Delete(ctx context.Context, id int64) error // Search runs a full-text + attribute query and returns the ranked hits // for the requested page plus the total match count (across all pages). Search(ctx context.Context, q SearchQuery) (hits []SearchHit, total int, err error) } // TenantIndexer hands out per-tenant Indexer instances, each backed by its own // RT table (documents_tenant_). type TenantIndexer interface { ForTenant(tenantID int64) Indexer Close() error }