Skip to content

Commit da4d369

Browse files
jope-bmclaude
andauthored
feat: add created_by and last_updated_by user tracking to Entity (#602)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0f3889f commit da4d369

6 files changed

Lines changed: 279 additions & 0 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""Add created_by and last_updated_by columns to entity table.
2+
3+
Revision ID: k4e5f6g7h8i9
4+
Revises: j3d4e5f6g7h8
5+
Create Date: 2026-02-23 00:00:00.000000
6+
7+
These columns track which cloud user created and last modified each entity.
8+
Both are nullable — NULL for local/CLI usage and existing entities.
9+
"""
10+
11+
from typing import Sequence, Union
12+
13+
import sqlalchemy as sa
14+
from alembic import op
15+
from sqlalchemy import text
16+
17+
# revision identifiers, used by Alembic.
18+
revision: str = "k4e5f6g7h8i9"
19+
down_revision: Union[str, None] = "j3d4e5f6g7h8"
20+
branch_labels: Union[str, Sequence[str], None] = None
21+
depends_on: Union[str, Sequence[str], None] = None
22+
23+
24+
def column_exists(connection, table: str, column: str) -> bool:
25+
"""Check if a column exists in a table (idempotent migration support)."""
26+
if connection.dialect.name == "postgresql":
27+
result = connection.execute(
28+
text(
29+
"SELECT 1 FROM information_schema.columns "
30+
"WHERE table_name = :table AND column_name = :column"
31+
),
32+
{"table": table, "column": column},
33+
)
34+
return result.fetchone() is not None
35+
else:
36+
# SQLite
37+
result = connection.execute(text(f"PRAGMA table_info({table})"))
38+
columns = [row[1] for row in result]
39+
return column in columns
40+
41+
42+
def upgrade() -> None:
43+
"""Add created_by and last_updated_by columns to entity table.
44+
45+
Both columns are nullable strings that store cloud user_profile_id UUIDs.
46+
No data backfill — existing rows get NULL.
47+
"""
48+
connection = op.get_bind()
49+
50+
if not column_exists(connection, "entity", "created_by"):
51+
op.add_column("entity", sa.Column("created_by", sa.String(), nullable=True))
52+
53+
if not column_exists(connection, "entity", "last_updated_by"):
54+
op.add_column("entity", sa.Column("last_updated_by", sa.String(), nullable=True))
55+
56+
57+
def downgrade() -> None:
58+
"""Remove created_by and last_updated_by columns from entity table."""
59+
connection = op.get_bind()
60+
dialect = connection.dialect.name
61+
62+
if column_exists(connection, "entity", "last_updated_by"):
63+
if dialect == "postgresql":
64+
op.drop_column("entity", "last_updated_by")
65+
else:
66+
with op.batch_alter_table("entity") as batch_op:
67+
batch_op.drop_column("last_updated_by")
68+
69+
if column_exists(connection, "entity", "created_by"):
70+
if dialect == "postgresql":
71+
op.drop_column("entity", "created_by")
72+
else:
73+
with op.batch_alter_table("entity") as batch_op:
74+
batch_op.drop_column("created_by")

src/basic_memory/models/knowledge.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,11 @@ class Entity(Base):
9494
onupdate=lambda: datetime.now().astimezone(),
9595
)
9696

97+
# Who created this entity (cloud user_profile_id UUID, null for local/CLI usage)
98+
created_by: Mapped[Optional[str]] = mapped_column(String, nullable=True, default=None)
99+
# Who last modified this entity (cloud user_profile_id UUID, null for local/CLI usage)
100+
last_updated_by: Mapped[Optional[str]] = mapped_column(String, nullable=True, default=None)
101+
97102
# Relationships
98103
project = relationship("Project", back_populates="entities")
99104
observations = relationship(

src/basic_memory/schemas/v2/entity.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,10 @@ class EntityResponseV2(BaseModel):
139139
created_at: datetime = Field(..., description="Creation timestamp")
140140
updated_at: datetime = Field(..., description="Last update timestamp")
141141

142+
# User tracking (cloud only, null for local/CLI usage)
143+
created_by: Optional[str] = Field(None, description="User profile ID of creator")
144+
last_updated_by: Optional[str] = Field(None, description="User profile ID of last editor")
145+
142146
# V2-specific metadata
143147
api_version: Literal["v2"] = Field(
144148
default="v2", description="API version (always 'v2' for this response)"

src/basic_memory/services/entity_service.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""Service for managing entities in the database."""
22

3+
from collections.abc import Callable
34
from datetime import datetime
45
from pathlib import Path
56
from typing import List, Optional, Sequence, Tuple, Union
@@ -68,6 +69,9 @@ def __init__(
6869
self.search_service = search_service
6970
self.app_config = app_config
7071
self._project_permalink: Optional[str] = None
72+
# Callable that returns the current user ID (cloud user_profile_id UUID as string).
73+
# Default returns None for local/CLI usage. Cloud overrides this to read from UserContext.
74+
self.get_user_id: Callable[[], Optional[str]] = lambda: None
7175

7276
async def detect_file_path_conflicts(
7377
self, file_path: str, skip_check: bool = False
@@ -445,7 +449,12 @@ async def fast_write_entity(
445449
"updated_at": datetime.now().astimezone(),
446450
}
447451

452+
user_id = self.get_user_id()
453+
448454
if existing:
455+
# Preserve existing created_by; only update last_updated_by
456+
if user_id is not None:
457+
update_data["last_updated_by"] = user_id
449458
updated = await self.repository.update(existing.id, update_data)
450459
if not updated:
451460
raise ValueError(f"Failed to update entity in database: {existing.id}")
@@ -454,6 +463,9 @@ async def fast_write_entity(
454463
create_data = dict(update_data)
455464
if external_id is not None:
456465
create_data["external_id"] = external_id
466+
if user_id is not None:
467+
create_data["created_by"] = user_id
468+
create_data["last_updated_by"] = user_id
457469
return await self.repository.create(create_data)
458470

459471
async def fast_edit_entity(
@@ -481,6 +493,10 @@ async def fast_edit_entity(
481493
"checksum": checksum,
482494
"updated_at": datetime.now().astimezone(),
483495
}
496+
user_id = self.get_user_id()
497+
if user_id is not None:
498+
update_data["last_updated_by"] = user_id
499+
484500
content_markdown = None
485501
if has_frontmatter(new_content):
486502
content_frontmatter = parse_frontmatter(new_content)
@@ -611,6 +627,12 @@ async def create_entity_from_markdown(
611627
# Mark as incomplete because we still need to add relations
612628
model.checksum = None
613629

630+
# Set user tracking fields for cloud usage
631+
user_id = self.get_user_id()
632+
if user_id is not None:
633+
model.created_by = user_id
634+
model.last_updated_by = user_id
635+
614636
# Use UPSERT to handle conflicts cleanly
615637
try:
616638
return await self.repository.upsert_entity(model)
@@ -653,6 +675,11 @@ async def update_entity_and_observations(
653675
# checksum value is None == not finished with sync
654676
db_entity.checksum = None
655677

678+
# Set last_updated_by for cloud usage (preserve existing created_by)
679+
user_id = self.get_user_id()
680+
if user_id is not None:
681+
db_entity.last_updated_by = user_id
682+
656683
# update entity
657684
return await self.repository.update(
658685
db_entity.id,

tests/api/v2/test_knowledge_router.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -833,3 +833,24 @@ async def test_delete_directory_v2_nested_structure(client: AsyncClient, v2_proj
833833
assert result.total_files == 2
834834
assert result.successful_deletes == 2
835835
assert result.failed_deletes == 0
836+
837+
838+
@pytest.mark.asyncio
839+
async def test_entity_response_includes_user_tracking_fields(
840+
client: AsyncClient, v2_project_url
841+
):
842+
"""EntityResponseV2 includes created_by and last_updated_by fields (null for local)."""
843+
entity_data = {
844+
"title": "UserTrackingTest",
845+
"directory": "test",
846+
"content": "Test content",
847+
}
848+
response = await client.post(f"{v2_project_url}/knowledge/entities", json=entity_data)
849+
assert response.status_code == 200
850+
851+
body = response.json()
852+
# Fields should be present in the response (null for local/CLI usage)
853+
assert "created_by" in body
854+
assert "last_updated_by" in body
855+
assert body["created_by"] is None
856+
assert body["last_updated_by"] is None

tests/services/test_entity_service.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2017,3 +2017,151 @@ async def test_create_or_update_entity_fuzzy_search_bug(
20172017
assert "Original content for Node A" not in content_c, (
20182018
"Node C.md should not contain Node A content"
20192019
)
2020+
2021+
2022+
# --- User Tracking (created_by / last_updated_by) ---
2023+
2024+
2025+
@pytest.mark.asyncio
2026+
async def test_created_by_null_by_default(entity_service: EntityService):
2027+
"""created_by and last_updated_by are NULL when get_user_id returns None (local/CLI usage)."""
2028+
schema = EntitySchema(
2029+
title="Local Entity",
2030+
directory="test",
2031+
entity_type="note",
2032+
)
2033+
entity = await entity_service.create_entity(schema)
2034+
assert entity.created_by is None
2035+
assert entity.last_updated_by is None
2036+
2037+
2038+
@pytest.mark.asyncio
2039+
async def test_created_by_set_when_get_user_id_returns_value(entity_service: EntityService):
2040+
"""created_by and last_updated_by are set when get_user_id returns a user ID."""
2041+
user_id = str(uuid.uuid4())
2042+
entity_service.get_user_id = lambda: user_id
2043+
2044+
schema = EntitySchema(
2045+
title="Cloud Entity",
2046+
directory="test",
2047+
entity_type="note",
2048+
)
2049+
entity = await entity_service.create_entity(schema)
2050+
assert entity.created_by == user_id
2051+
assert entity.last_updated_by == user_id
2052+
2053+
2054+
@pytest.mark.asyncio
2055+
async def test_update_preserves_created_by(entity_service: EntityService):
2056+
"""Updating an entity preserves created_by and updates last_updated_by."""
2057+
creator_id = str(uuid.uuid4())
2058+
editor_id = str(uuid.uuid4())
2059+
2060+
# Create as creator
2061+
entity_service.get_user_id = lambda: creator_id
2062+
schema = EntitySchema(
2063+
title="Owned Entity",
2064+
directory="test",
2065+
entity_type="note",
2066+
content="Original content",
2067+
)
2068+
entity = await entity_service.create_entity(schema)
2069+
assert entity.created_by == creator_id
2070+
2071+
# Update as editor
2072+
entity_service.get_user_id = lambda: editor_id
2073+
update_schema = EntitySchema(
2074+
title="Owned Entity",
2075+
directory="test",
2076+
entity_type="note",
2077+
content="Updated content",
2078+
)
2079+
updated = await entity_service.update_entity(entity, update_schema)
2080+
assert updated.created_by == creator_id # preserved
2081+
assert updated.last_updated_by == editor_id # updated
2082+
2083+
2084+
@pytest.mark.asyncio
2085+
async def test_fast_write_entity_sets_user_tracking(entity_service: EntityService):
2086+
"""fast_write_entity sets created_by and last_updated_by on create."""
2087+
user_id = str(uuid.uuid4())
2088+
entity_service.get_user_id = lambda: user_id
2089+
2090+
schema = EntitySchema(
2091+
title="Fast Write Tracked",
2092+
directory="test",
2093+
entity_type="note",
2094+
)
2095+
entity = await entity_service.fast_write_entity(schema, external_id=str(uuid.uuid4()))
2096+
assert entity.created_by == user_id
2097+
assert entity.last_updated_by == user_id
2098+
2099+
2100+
@pytest.mark.asyncio
2101+
async def test_fast_write_entity_update_preserves_created_by(entity_service: EntityService):
2102+
"""fast_write_entity update path preserves created_by, sets last_updated_by."""
2103+
creator_id = str(uuid.uuid4())
2104+
editor_id = str(uuid.uuid4())
2105+
external_id = str(uuid.uuid4())
2106+
2107+
# Create
2108+
entity_service.get_user_id = lambda: creator_id
2109+
schema = EntitySchema(
2110+
title="Fast Write Update",
2111+
directory="test",
2112+
entity_type="note",
2113+
)
2114+
entity = await entity_service.fast_write_entity(schema, external_id=external_id)
2115+
assert entity.created_by == creator_id
2116+
2117+
# Update (same external_id triggers update path)
2118+
entity_service.get_user_id = lambda: editor_id
2119+
update_schema = EntitySchema(
2120+
title="Fast Write Update",
2121+
directory="test",
2122+
entity_type="note",
2123+
content="Updated",
2124+
)
2125+
updated = await entity_service.fast_write_entity(update_schema, external_id=external_id)
2126+
assert updated.created_by == creator_id # preserved
2127+
assert updated.last_updated_by == editor_id # updated
2128+
2129+
2130+
@pytest.mark.asyncio
2131+
async def test_fast_edit_entity_sets_last_updated_by(entity_service: EntityService):
2132+
"""fast_edit_entity sets last_updated_by on edit."""
2133+
creator_id = str(uuid.uuid4())
2134+
editor_id = str(uuid.uuid4())
2135+
2136+
# Create entity first
2137+
entity_service.get_user_id = lambda: creator_id
2138+
schema = EntitySchema(
2139+
title="Fast Edit Tracked",
2140+
directory="test",
2141+
entity_type="note",
2142+
content="Original content",
2143+
)
2144+
entity = await entity_service.fast_write_entity(schema, external_id=str(uuid.uuid4()))
2145+
2146+
# Edit as different user
2147+
entity_service.get_user_id = lambda: editor_id
2148+
edited = await entity_service.fast_edit_entity(
2149+
entity=entity,
2150+
operation="append",
2151+
content="\nAppended content",
2152+
)
2153+
assert edited.created_by == creator_id # preserved
2154+
assert edited.last_updated_by == editor_id # updated
2155+
2156+
2157+
@pytest.mark.asyncio
2158+
async def test_fast_write_entity_null_user_id(entity_service: EntityService):
2159+
"""fast_write_entity with default get_user_id (None) leaves tracking fields null."""
2160+
schema = EntitySchema(
2161+
title="No User Tracking",
2162+
directory="test",
2163+
entity_type="note",
2164+
)
2165+
entity = await entity_service.fast_write_entity(schema, external_id=str(uuid.uuid4()))
2166+
assert entity.created_by is None
2167+
assert entity.last_updated_by is None

0 commit comments

Comments
 (0)