3.8 KiB
3.8 KiB
vault search
(no submodules)
Purpose
search.ts implements free-text search across vault items with category / favorite / tag filters. The result projection deliberately strips concealedFields, so search results NEVER contain plaintext passwords / PINs / secure notes — even if a caller forgets to do its own filtering.
The implementation is a linear scan over the non-deleted rows; with the schema-enforced caps (1000-row limit, 256-char query, 32-tag filter) this remains O(N × Q) bounded well under millisecond latency for typical vaults. Future versions may swap in an FTS5 in-memory index without changing the public surface.
Features
search(query) → SearchResult[]— Zod-validated free-text query with optionalcategory/tags/favoritefilters and result limit.- Scoring weights — title = 4, plaintext fields = 2, tags = 1; ties break by insertion order via stable sort.
- Tag filter is intersection (every tag in
query.tagsmust appear on the row). - Empty
querystring returns every matching row (filtered) — useful for filter-only listing. - Concealed-field plaintext is structurally excluded from the result type — the projection uses
Omit<VaultItem, "concealedFields">. - Every search records a histogram sample and an
op: "search"outcome counter.
Usage: Basic Example
import { VaultSearch } from "@suaveplan/vault/search/search";
const search = new VaultSearch({
rows: () => items.allRows(),
vaultKey,
clock: Date.now,
});
const results = search.search({ query: "github", category: "login" });
for (const item of results) {
console.log(item.title, item.fields.username);
// item.concealedFields is `never` here — type-checked exclusion.
}
Usage: Advanced Example
import { VaultSearch } from "@suaveplan/vault/search/search";
const search = new VaultSearch({ rows, vaultKey, clock });
// Tag intersection — only items with BOTH "work" AND "dev".
const workDev = search.search({ tags: ["work", "dev"], limit: 50 });
// Favorite-only listing with no text filter.
const favorites = search.search({ favorite: true });
// Empty query, category filter, custom limit.
const allLogins = search.search({ category: "login", limit: 20 });
API Reference
class VaultSearch.constructor(options: SearchOptions).search(query: SearchQuery): SearchResult[]— throwsITEM.VALIDATION_FAILEDon schema violation.
interface SearchOptions—{ rows: () => VaultItemRow[], vaultKey, clock }.type SearchResult = Omit<VaultItem, "concealedFields">— search projection without concealed plaintext.
Implementation Notes
- The result projection strips
concealedFieldsvia destructuring; this guarantees no concealed-field plaintext can flow to a search caller, even if a future code path adds new concealed fields. TheSearchResulttype alias propagates the exclusion to TypeScript consumers. - Scoring is deterministic and field-weighted; titles dominate so a query like
"github"ranks{ title: "GitHub" }ahead of an item that merely mentions GitHub in a username field. Ties retain insertion order via the stableArray.prototype.sort. - The
rowscallback is invoked on every search so the search index always reflects the latest state. Building a persistent index across multiple searches is intentionally deferred to a future version. - The injected
vaultKeyis needed by the projection to decrypt nothing — concealed-field values are stripped before decryption — butrowToItemstill derives the per-item key during projection. This keeps the projection pure and avoids special-casing search insiderowToItem. - The OTel counter uses
op: "search"— search is treated as a read-side item op for telemetry purposes, sharing the items histogram + counter to keep the metric surface narrow.