Files
secrets/plans/metadata-search-and-entry-relations.md
agent 089d0b4b58
All checks were successful
Secrets MCP — Build & Release / 检查 / 构建 / 发版 (push) Successful in 6m30s
Secrets MCP — Build & Release / 部署 secrets-mcp (push) Successful in 1m37s
style(dashboard): move version footer out of card
2026-04-09 17:32:40 +08:00

392 lines
15 KiB
Markdown

# Metadata Value Search & Entry Relations (DAG)
## Overview
Two new features for secrets-mcp:
1. **Metadata Value Search** — fuzzy search across all JSON scalar values in `metadata`, excluding keys
2. **Entry Relations** — directional parent-child associations between entries (DAG, multiple parents allowed, cycle detection)
---
## Feature 1: Metadata Value Search
### Problem
The existing `query` parameter in `secrets_find`/`secrets_search` searches `metadata::text ILIKE`, which matches keys, JSON punctuation, and structural characters. Users want to search **only metadata values** (e.g. find entries where any metadata value contains "1.2.3.4", regardless of key name).
### Solution
Add a new `metadata_query` filter to `SearchParams` that uses PostgreSQL `jsonb_path_query` to iterate over only scalar values (strings, numbers, booleans), then applies ILIKE matching.
### Changes
#### secrets-core
**`crates/secrets-core/src/service/search.rs`**
- Add `metadata_query: Option<&'a str>` field to `SearchParams`
- In `entry_where_clause_and_next_idx`, when `metadata_query` is set, add:
```sql
EXISTS (
SELECT 1 FROM jsonb_path_query(
entries.metadata,
'strict $.** ? (@.type() != "object" && @.type() != "array")'
) AS val
WHERE (val#>>'{}') ILIKE $N ESCAPE '\'
)
```
- Bind `ilike_pattern(metadata_query)` at the correct `$N` position in both `fetch_entries_paged` and `count_entries`
#### secrets-mcp (MCP tools)
**`crates/secrets-mcp/src/tools.rs`**
- Add `metadata_query` field to `FindInput`:
```rust
#[schemars(description = "Fuzzy search across metadata values only (keys excluded)")]
metadata_query: Option<String>,
```
- Add same field to `SearchInput`
- Pass `metadata_query` through to `SearchParams` in both `secrets_find` and `secrets_search` handlers
#### secrets-mcp (Web)
**`crates/secrets-mcp/src/web/entries.rs`**
- Add `metadata_query: Option<String>` to `EntriesQuery`
- Thread it into all `SearchParams` usages (count, list, folder counts)
- Pass it into template context
- Add `metadata_query` to `EntriesPageTemplate` and filter form hidden fields
- Include `metadata_query` in pagination `href` links
**`crates/secrets-mcp/templates/entries.html`**
- Add a "metadata 值" text input to the filter bar (after name, before type)
- Preserve value in the input on re-render
### i18n Keys
| Key | zh | zh-Hant | en |
|-----|-----|---------|-----|
| `filterMetaLabel` | 元数据值 | 元数据值 | Metadata value |
| `filterMetaPlaceholder` | 搜索元数据值 | 搜尋元資料值 | Search metadata values |
### Performance Notes
- The `jsonb_path_query` with `$.**` scans all nested values recursively; this is a sequential scan on the metadata column per row
- The existing GIN index on `metadata jsonb_path_ops` supports `@>` containment queries but NOT this pattern
- For production datasets > 10k entries, consider a generated column or materialized search column in a future iteration
- First version prioritizes semantic correctness over index optimization
---
## Feature 2: Entry Relations (DAG)
### Data Model
New table `entry_relations`:
```sql
CREATE TABLE IF NOT EXISTS entry_relations (
parent_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
child_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (parent_entry_id, child_entry_id),
CHECK (parent_entry_id <> child_entry_id)
);
CREATE INDEX idx_entry_relations_parent ON entry_relations(parent_entry_id);
CREATE INDEX idx_entry_relations_child ON entry_relations(child_entry_id);
-- Enforce multi-tenant isolation: parent and child must belong to same user
ALTER TABLE entry_relations ADD CONSTRAINT fk_parent_user
FOREIGN KEY (parent_entry_id) REFERENCES entries(id) ON DELETE CASCADE;
ALTER TABLE entry_relations ADD CONSTRAINT fk_child_user
FOREIGN KEY (child_entry_id) REFERENCES entries(id) ON DELETE CASCADE;
```
Shared secrets already use `entry_secrets` as an N:N relation, so this is consistent with the existing pattern.
### Cycle Detection
On every `INSERT INTO entry_relations(parent, child)`, check that no path exists from `child` back to `parent`:
```sql
-- Returns true if adding (parent, child) would create a cycle
SELECT EXISTS(
SELECT 1 FROM entry_relations
WHERE child_entry_id = $1 -- $1 = proposed parent
START WITH parent_entry_id = $2 -- $2 = proposed child
CONNECT BY PRIOR child_entry_id = parent_entry_id
);
```
Wait — PostgreSQL doesn't support `START WITH ... CONNECT BY`. Use recursive CTE instead:
```sql
WITH RECURSIVE chain AS (
SELECT parent_entry_id AS ancestor
FROM entry_relations
WHERE child_entry_id = $1 -- proposed child
UNION ALL
SELECT er.parent_entry_id
FROM entry_relations er
JOIN chain c ON c.ancestor = er.child_entry_id
)
SELECT EXISTS(SELECT 1 FROM chain WHERE ancestor = $2);
-- $1 = proposed child, $2 = proposed parent
```
If `EXISTS` returns true, reject with `AppError::Validation { message: "cycle detected" }`.
### secrets-core Changes
**New file: `crates/secrets-core/src/service/relations.rs`**
```rust
pub struct RelationSummary {
pub parent_id: Uuid,
pub parent_name: String,
pub parent_folder: String,
pub parent_type: String,
}
pub struct AddRelationParams<'a> {
pub parent_entry_id: Uuid,
pub child_entry_id: Uuid,
pub user_id: Option<Uuid>,
}
pub struct RemoveRelationParams<'a> {
pub parent_entry_id: Uuid,
pub child_entry_id: Uuid,
pub user_id: Option<Uuid>,
}
/// Add a parent→child relation. Validates:
/// - Both entries exist and belong to the same user
/// - No self-reference (enforced by CHECK constraint)
/// - No cycle (recursive CTE check)
pub async fn add_relation(pool: &PgPool, params: AddRelationParams<'_>) -> Result<()>
/// Remove a parent→child relation.
pub async fn remove_relation(pool: &PgPool, params: RemoveRelationParams<'_>) -> Result<()>
/// Get all parents of an entry (with summary info).
pub async fn get_parents(pool: &PgPool, entry_id: Uuid, user_id: Option<Uuid>) -> Result<Vec<RelationSummary>>
/// Get all children of an entry (with summary info).
pub async fn get_children(pool: &PgPool, entry_id: Uuid, user_id: Option<Uuid>) -> Result<Vec<RelationSummary>>
/// Get parents + children for a batch of entry IDs (for list pages).
pub async fn get_relations_for_entries(
pool: &PgPool,
entry_ids: &[Uuid],
user_id: Option<Uuid>,
) -> Result<HashMap<Uuid, Vec<RelationSummary>>>
```
**`crates/secrets-core/src/service/mod.rs`** — add `pub mod relations;`
**`crates/secrets-core/src/db.rs`** — add entry_relations table creation in `migrate()`
**`crates/secrets-core/src/error.rs`** — no new error variant needed; use `AppError::Validation { message }` for cycle detection and permission errors
### MCP Tool Changes
**`crates/secrets-mcp/src/tools.rs`**
1. **`secrets_add`** (`AddInput`): add optional `parent_ids: Option<Vec<String>>` field
- Description: "UUIDs of parent entries to link. Creates parent→child relations."
- After creating the entry, call `relations::add_relation` for each parent
2. **`secrets_update`** (`UpdateInput`): add two fields:
- `add_parent_ids: Option<Vec<String>>` — "UUIDs of parent entries to link"
- `remove_parent_ids: Option<Vec<String>>` — "UUIDs of parent entries to unlink"
3. **`secrets_find`** and `secrets_search` output: add `parents` and `children` arrays to each entry result:
```json
{
"id": "...",
"name": "...",
"parents": [{"id": "...", "name": "...", "folder": "...", "type": "..."}],
"children": [{"id": "...", "name": "...", "folder": "...", "type": "..."}]
}
```
- Fetch relations for all returned entry IDs in a single batch query
### Web Changes
**`crates/secrets-mcp/src/web/entries.rs`**
1. **New API endpoints:**
- `POST /api/entries/{id}/relations` — add parent relation
- Body: `{ "parent_id": "uuid" }`
- Validates same-user ownership and cycle detection
- `DELETE /api/entries/{id}/relations/{parent_id}` — remove parent relation
- `GET /api/entries/options?q=xxx` — lightweight search for parent selection modal
- Returns `[{ "id": "...", "name": "...", "folder": "...", "type": "..." }]`
- Used by the edit dialog's parent selection autocomplete
2. **Entry list template data** — include parent/child counts per entry row
3. **`api_entry_patch`** — extend `EntryPatchBody` with optional `parent_ids: Option<Vec<Uuid>>`
- When present, replace all parent relations for this entry with the given list
- This is simpler than incremental add/remove in the Web UI context
**`crates/secrets-mcp/templates/entries.html`**
1. **List table**: add a "关联" (relations) column showing parent/child counts as clickable chips
2. **Edit dialog**: add "上级条目" (parent entries) section
- Show current parents as removable chips
- Add a search-as-you-type input that queries `/api/entries/options`
- Click a search result to add it as parent
- On save, send `parent_ids` in the PATCH body
3. **View dialog / detail**: show "下级条目" (children) list with clickable links that navigate to the child entry
4. **i18n**: add keys for all new UI elements
### i18n Keys (Entry Relations)
| Key | zh | zh-Hant | en |
|-----|-----|---------|-----|
| `colRelations` | 关联 | 關聯 | Relations |
| `parentEntriesLabel` | 上级条目 | 上級條目 | Parent entries |
| `childrenEntriesLabel` | 下级条目 | 下級條目 | Child entries |
| `addParentLabel` | 添加上级 | 新增上級 | Add parent |
| `removeParentLabel` | 移除上级 | 移除上級 | Remove parent |
| `searchEntriesPlaceholder` | 搜索条目… | 搜尋條目… | Search entries… |
| `noParents` | 无上级 | 無上級 | No parents |
| `noChildren` | 无下级 | 無下級 | No children |
| `relationCycleError` | 无法添加:会形成循环引用 | 無法新增:會形成循環引用 | Cannot add: would create a cycle |
### Audit Logging
Log relation changes in the existing `audit::log_tx` system:
- Action: `"add_relation"` / `"remove_relation"`
- Detail JSON: `{ "parent_id": "...", "parent_name": "...", "child_id": "...", "child_name": "..." }`
### Export / Import
**`ExportEntry`** — add optional `parents: Vec<ParentRef>` where:
```rust
pub struct ParentRef {
pub folder: String,
pub name: String,
}
```
- On export, resolve each entry's parent IDs to `(folder, name)` pairs
- On import, two-phase:
1. Create all entries (skip parents)
2. For each entry with `parents`, resolve `(folder, name)` → `entry_id` and call `add_relation`
3. If a parent reference cannot be resolved, log a warning and skip it (don't fail the entire import)
### History / Rollback
- Relation changes are **not** versioned in `entries_history`. They are tracked only via `audit_log`.
- Rationale: relations are a cross-entry concern; rolling them back alongside entry fields would require complex multi-entry coordination. The audit log provides sufficient traceability.
- If the user explicitly requests rollback of relations in the future, it can be implemented as a separate feature.
---
## Implementation Order
### Phase 1: Metadata Value Search
1. `secrets-core/src/service/search.rs` — add `metadata_query` to `SearchParams`, implement SQL condition
2. `secrets-mcp/src/tools.rs` — add `metadata_query` to `FindInput` and `SearchInput`, wire through
3. `secrets-mcp/src/web/entries.rs` — add `metadata_query` to `EntriesQuery`, `SearchParams`, pagination, folder counts
4. `secrets-mcp/templates/entries.html` — add input field, i18n
5. Test: existing `query` still works; `metadata_query` only matches values
### Phase 2: Entry Relations (Core)
1. `secrets-core/src/db.rs` — add `entry_relations` table to `migrate()`
2. `secrets-core/src/service/relations.rs` — implement `add_relation`, `remove_relation`, `get_parents`, `get_children`, `get_relations_for_entries`, cycle detection
3. `secrets-core/src/service/mod.rs` — add `pub mod relations`
4. Test: add/remove/query relations, cycle detection, same-user validation
### Phase 3: Entry Relations (MCP)
1. `secrets-mcp/src/tools.rs` — extend `AddInput`, `UpdateInput` with parent IDs
2. `secrets-mcp/src/tools.rs` — extend `secrets_find`/`secrets_search` output with `parents`/`children`
3. Test: MCP tools work end-to-end
### Phase 4: Entry Relations (Web)
1. `secrets-mcp/src/web/entries.rs` — add API endpoints, extend `EntryPatchBody`, extend template data
2. `secrets-mcp/templates/entries.html` — add relations column, edit dialog parent selector, view dialog children list
3. Test: Web UI works end-to-end
### Phase 5: Export / Import (Optional)
1. `secrets-core/src/models.rs` — add `parents` to `ExportEntry`
2. `secrets-core/src/service/export.rs` — populate parents
3. `secrets-core/src/service/import.rs` — two-phase import with relation resolution
---
## Database Migration
Add to `secrets-core/src/db.rs` `migrate()`:
```sql
CREATE TABLE IF NOT EXISTS entry_relations (
parent_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
child_entry_id UUID NOT NULL REFERENCES entries(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (parent_entry_id, child_entry_id),
CHECK (parent_entry_id <> child_entry_id)
);
CREATE INDEX IF NOT EXISTS idx_entry_relations_parent ON entry_relations(parent_entry_id);
CREATE INDEX IF NOT EXISTS idx_entry_relations_child ON entry_relations(child_entry_id);
```
This is idempotent (uses `IF NOT EXISTS`) and will run automatically on next startup.
---
## Security Considerations
- **Same-user isolation**: `add_relation` must verify both `parent_entry_id` and `child_entry_id` belong to the same `user_id` (or both are `NULL` for legacy single-user mode)
- **Cycle detection**: Recursive CTE query prevents any directed cycle, regardless of depth
- **CASCADE delete**: When an entry is deleted, all its relation edges are automatically removed via the `ON DELETE CASCADE` foreign key. This is the same pattern used by `entry_secrets`.
---
## Testing Checklist
### Metadata Search
- [ ] `metadata_query=1.2.3.4` matches entries where any metadata value contains "1.2.3.4"
- [ ] `metadata_query=1.2.3.4` does NOT match entries where only the key contains "1.2.3.4"
- [ ] `metadata_query` works with nested metadata (e.g. `{"server": {"ip": "1.2.3.4"}}`)
- [ ] `metadata_query` combined with `folder`/`type`/`tags` filters works correctly
- [ ] `metadata_query` with special characters (`%`, `_`) is properly escaped
- [ ] Existing `query` parameter behavior is unchanged
- [ ] Web filter bar preserves `metadata_query` across pagination and folder tab clicks
### Entry Relations
- [ ] Can add a parent→child relation between two entries
- [ ] Can add multiple parents to a single entry
- [ ] Cannot add self-referencing relation (CHECK constraint)
- [ ] Cannot create a direct cycle (A→B→A)
- [ ] Cannot create an indirect cycle (A→B→C→A)
- [ ] Cannot link entries from different users
- [ ] Deleting an entry removes all its relation edges but leaves related entries intact
- [ ] MCP `secrets_add` with `parent_ids` creates relations
- [ ] MCP `secrets_update` with `add_parent_ids`/`remove_parent_ids` modifies relations
- [ ] MCP `secrets_find`/`secrets_search` output includes `parents` and `children`
- [ ] Web entry list shows relation counts
- [ ] Web edit dialog allows adding/removing parents
- [ ] Web entry view shows children with navigation links