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:
208
database.py
208
database.py
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user