Add friendly names system for recipes, effects, and media

- Add friendly_names table with unique constraints per actor
- Create NamingService with HMAC-signed timestamp version IDs
- Version IDs use base32-crockford encoding, always increase alphabetically
- Name normalization: spaces/underscores to dashes, lowercase, strip special chars
- Format: "my-effect 01hw3x9k" (space separator ensures uniqueness)
- Integrate naming into recipe, effect, and media uploads
- Resolve friendly names to CIDs during DAG execution
- Update effects UI to display friendly names
- Add 30 tests covering normalization, parsing, and service structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gilesb
2026-01-12 14:02:17 +00:00
parent 98ca2a6c81
commit 19634a4ac5
9 changed files with 814 additions and 8 deletions

View File

@@ -126,6 +126,23 @@ CREATE TABLE IF NOT EXISTS storage_pins (
UNIQUE(cid, storage_id)
);
-- Friendly names: human-readable versioned names for content
-- Version IDs are server-signed timestamps (always increasing, verifiable origin)
-- Names are per-user within L1; when shared to L2: @user@domain name version
CREATE TABLE IF NOT EXISTS friendly_names (
id SERIAL PRIMARY KEY,
actor_id VARCHAR(255) NOT NULL,
base_name VARCHAR(255) NOT NULL, -- normalized: my-cool-effect
version_id VARCHAR(20) NOT NULL, -- server-signed timestamp: 01hw3x9kab2cd
cid VARCHAR(64) NOT NULL, -- content address
item_type VARCHAR(20) NOT NULL, -- recipe | effect | media
display_name VARCHAR(255), -- original "My Cool Effect"
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(actor_id, base_name, version_id),
UNIQUE(actor_id, cid) -- each CID has exactly one friendly name per user
);
-- Indexes
CREATE INDEX IF NOT EXISTS idx_item_types_cid ON item_types(cid);
CREATE INDEX IF NOT EXISTS idx_item_types_actor_id ON item_types(actor_id);
@@ -139,6 +156,10 @@ CREATE INDEX IF NOT EXISTS idx_storage_backends_actor ON storage_backends(actor_
CREATE INDEX IF NOT EXISTS idx_storage_backends_type ON storage_backends(provider_type);
CREATE INDEX IF NOT EXISTS idx_storage_pins_hash ON storage_pins(cid);
CREATE INDEX IF NOT EXISTS idx_storage_pins_storage ON storage_pins(storage_id);
CREATE INDEX IF NOT EXISTS idx_friendly_names_actor ON friendly_names(actor_id);
CREATE INDEX IF NOT EXISTS idx_friendly_names_type ON friendly_names(item_type);
CREATE INDEX IF NOT EXISTS idx_friendly_names_base ON friendly_names(actor_id, base_name);
CREATE INDEX IF NOT EXISTS idx_friendly_names_latest ON friendly_names(actor_id, item_type, base_name, created_at DESC);
"""
@@ -1558,3 +1579,190 @@ async def get_stale_pending_runs(older_than_hours: int = 24) -> List[dict]:
}
for row in rows
]
# ============ Friendly Names ============
async def create_friendly_name(
actor_id: str,
base_name: str,
version_id: str,
cid: str,
item_type: str,
display_name: Optional[str] = None,
) -> dict:
"""
Create a friendly name entry.
Returns the created entry.
"""
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
INSERT INTO friendly_names (actor_id, base_name, version_id, cid, item_type, display_name)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (actor_id, cid) DO UPDATE SET
base_name = EXCLUDED.base_name,
version_id = EXCLUDED.version_id,
display_name = EXCLUDED.display_name
RETURNING id, actor_id, base_name, version_id, cid, item_type, display_name, created_at
""",
actor_id, base_name, version_id, cid, item_type, display_name
)
return {
"id": row["id"],
"actor_id": row["actor_id"],
"base_name": row["base_name"],
"version_id": row["version_id"],
"cid": row["cid"],
"item_type": row["item_type"],
"display_name": row["display_name"],
"friendly_name": f"{row['base_name']} {row['version_id']}",
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
async def get_friendly_name_by_cid(actor_id: str, cid: str) -> Optional[dict]:
"""Get friendly name entry by CID."""
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT id, actor_id, base_name, version_id, cid, item_type, display_name, created_at
FROM friendly_names
WHERE actor_id = $1 AND cid = $2
""",
actor_id, cid
)
if row:
return {
"id": row["id"],
"actor_id": row["actor_id"],
"base_name": row["base_name"],
"version_id": row["version_id"],
"cid": row["cid"],
"item_type": row["item_type"],
"display_name": row["display_name"],
"friendly_name": f"{row['base_name']} {row['version_id']}",
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
return None
async def resolve_friendly_name(
actor_id: str,
name: str,
item_type: Optional[str] = None,
) -> Optional[str]:
"""
Resolve a friendly name to a CID.
Name can be:
- "base-name" -> resolves to latest version
- "base-name version-id" -> resolves to exact version
Returns CID or None if not found.
"""
parts = name.strip().split(' ')
base_name = parts[0]
version_id = parts[1] if len(parts) > 1 else None
async with pool.acquire() as conn:
if version_id:
# Exact version lookup
query = """
SELECT cid FROM friendly_names
WHERE actor_id = $1 AND base_name = $2 AND version_id = $3
"""
params = [actor_id, base_name, version_id]
if item_type:
query += " AND item_type = $4"
params.append(item_type)
return await conn.fetchval(query, *params)
else:
# Latest version lookup
query = """
SELECT cid FROM friendly_names
WHERE actor_id = $1 AND base_name = $2
"""
params = [actor_id, base_name]
if item_type:
query += " AND item_type = $3"
params.append(item_type)
query += " ORDER BY created_at DESC LIMIT 1"
return await conn.fetchval(query, *params)
async def list_friendly_names(
actor_id: str,
item_type: Optional[str] = None,
base_name: Optional[str] = None,
latest_only: bool = False,
) -> List[dict]:
"""
List friendly names for a user.
Args:
actor_id: User ID
item_type: Filter by type (recipe, effect, media)
base_name: Filter by base name
latest_only: If True, only return latest version of each base name
"""
async with pool.acquire() as conn:
if latest_only:
# Use DISTINCT ON to get latest version of each base name
query = """
SELECT DISTINCT ON (base_name)
id, actor_id, base_name, version_id, cid, item_type, display_name, created_at
FROM friendly_names
WHERE actor_id = $1
"""
params = [actor_id]
if item_type:
query += " AND item_type = $2"
params.append(item_type)
if base_name:
query += f" AND base_name = ${len(params) + 1}"
params.append(base_name)
query += " ORDER BY base_name, created_at DESC"
else:
query = """
SELECT id, actor_id, base_name, version_id, cid, item_type, display_name, created_at
FROM friendly_names
WHERE actor_id = $1
"""
params = [actor_id]
if item_type:
query += " AND item_type = $2"
params.append(item_type)
if base_name:
query += f" AND base_name = ${len(params) + 1}"
params.append(base_name)
query += " ORDER BY base_name, created_at DESC"
rows = await conn.fetch(query, *params)
return [
{
"id": row["id"],
"actor_id": row["actor_id"],
"base_name": row["base_name"],
"version_id": row["version_id"],
"cid": row["cid"],
"item_type": row["item_type"],
"display_name": row["display_name"],
"friendly_name": f"{row['base_name']} {row['version_id']}",
"created_at": row["created_at"].isoformat() if row["created_at"] else None,
}
for row in rows
]
async def delete_friendly_name(actor_id: str, cid: str) -> bool:
"""Delete a friendly name entry by CID."""
async with pool.acquire() as conn:
result = await conn.execute(
"DELETE FROM friendly_names WHERE actor_id = $1 AND cid = $2",
actor_id, cid
)
return "DELETE 1" in result