first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,70 @@
# vault search
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## 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 optional `category` / `tags` / `favorite` filters 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.tags` must appear on the row).
- Empty `query` string 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
```ts
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
```ts
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[]` — throws `ITEM.VALIDATION_FAILED` on schema violation.
- `interface SearchOptions` — `{ rows: () => VaultItemRow[], vaultKey, clock }`.
- `type SearchResult = Omit<VaultItem, "concealedFields">` — search projection without concealed plaintext.
## Implementation Notes
- The result projection strips `concealedFields` via destructuring; this guarantees no concealed-field plaintext can flow to a search caller, even if a future code path adds new concealed fields. The `SearchResult` type 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 stable `Array.prototype.sort`.
- The `rows` callback 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 `vaultKey` is needed by the projection to decrypt nothing — concealed-field values are stripped before decryption — but `rowToItem` still derives the per-item key during projection. This keeps the projection pure and avoids special-casing search inside `rowToItem`.
- 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.