From 490b2d6ae0a0597195a68d248eb148b8e0c931ac Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 18 May 2026 04:08:08 +0000 Subject: [PATCH 1/4] feat(clickhouse): rename actor columns and indexes (actorId/actorType/actorInternalId) Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Audit/Adapter/ClickHouse.php | 80 ++++++++++++++++++++++++++------ 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/src/Audit/Adapter/ClickHouse.php b/src/Audit/Adapter/ClickHouse.php index 86b6f02..894f721 100644 --- a/src/Audit/Adapter/ClickHouse.php +++ b/src/Audit/Adapter/ClickHouse.php @@ -337,10 +337,18 @@ public function getAttributes(): array { $parentAttributes = parent::getAttributes(); + foreach ($parentAttributes as &$attribute) { + if (($attribute['$id'] ?? null) === 'userId') { + $attribute['$id'] = 'actorId'; + break; + } + } + unset($attribute); + return [ ...$parentAttributes, [ - '$id' => 'userType', + '$id' => 'actorType', 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'required' => true, @@ -350,7 +358,7 @@ public function getAttributes(): array 'filters' => [], ], [ - '$id' => 'userInternalId', + '$id' => 'actorInternalId', 'type' => Database::VAR_STRING, 'size' => Database::LENGTH_KEY, 'required' => false, @@ -477,13 +485,21 @@ public function getIndexes(): array { $parentIndexes = parent::getIndexes(); - // New indexes to add + foreach ($parentIndexes as &$index) { + if (($index['$id'] ?? null) === 'idx_userId_event') { + $index['$id'] = 'idx_actorId_event'; + $index['attributes'] = ['actorId', 'event']; + break; + } + } + unset($index); + return [ ...$parentIndexes, [ - '$id' => '_key_user_internal_and_event', + '$id' => '_key_actor_internal_and_event', 'type' => Database::INDEX_KEY, - 'attributes' => ['userInternalId', 'event'], + 'attributes' => ['actorInternalId', 'event'], 'lengths' => [], 'orders' => [], ], @@ -502,16 +518,16 @@ public function getIndexes(): array 'orders' => [], ], [ - '$id' => '_key_user_internal_id', + '$id' => '_key_actor_internal_id', 'type' => Database::INDEX_KEY, - 'attributes' => ['userInternalId'], + 'attributes' => ['actorInternalId'], 'lengths' => [], 'orders' => [], ], [ - '$id' => '_key_user_type', + '$id' => '_key_actor_type', 'type' => Database::INDEX_KEY, - 'attributes' => ['userType'], + 'attributes' => ['actorType'], 'lengths' => [], 'orders' => [], ], @@ -770,6 +786,22 @@ private function getColumnNames(): array * @return bool True if valid * @throws Exception If attribute name is invalid */ + /** + * Translate legacy user* attribute names to actor* column names. + * + * @param string $attribute + * @return string + */ + private function translateAttribute(string $attribute): string + { + return match ($attribute) { + 'userId' => 'actorId', + 'userType' => 'actorType', + 'userInternalId' => 'actorInternalId', + default => $attribute, + }; + } + private function validateAttributeName(string $attributeName): bool { // Special case: 'id' is always valid @@ -1110,6 +1142,7 @@ private function parseQueries(array $queries): array $method = $query->getMethod(); $attribute = $query->getAttribute(); /** @var string $attribute */ + $attribute = $this->translateAttribute($attribute); $values = $query->getValues(); // Reject empty values for filter methods that take values — mirrors @@ -1651,9 +1684,24 @@ public function createBatch(array $logs): bool $rows = []; foreach ($logs as $log) { + foreach (['userId' => 'actorId', 'userType' => 'actorType', 'userInternalId' => 'actorInternalId'] as $legacy => $current) { + if (\array_key_exists($legacy, $log) && !\array_key_exists($current, $log)) { + $log[$current] = $log[$legacy]; + } + unset($log[$legacy]); + } + /** @var array $logData */ $logData = $log['data'] ?? []; + foreach (['userId' => 'actorId', 'userType' => 'actorType', 'userInternalId' => 'actorInternalId'] as $legacy => $current) { + if (\array_key_exists($legacy, $logData) && !\array_key_exists($current, $logData)) { + $logData[$current] = $logData[$legacy]; + } + unset($logData[$legacy]); + } + $log['data'] = $logData; + // Separate data for non-schema attributes $nonSchemaData = $logData; $resourceValue = $log['resource'] ?? null; @@ -1805,6 +1853,12 @@ private function parseJsonResults(string $result): array unset($document['id']); } + foreach (['actorId' => 'userId', 'actorType' => 'userType', 'actorInternalId' => 'userInternalId'] as $current => $legacy) { + if (\array_key_exists($current, $document) && !\array_key_exists($legacy, $document)) { + $document[$legacy] = $document[$current]; + } + } + $documents[] = new Log($document); } @@ -1904,7 +1958,7 @@ public function getByUser( bool $ascending = false, ): array { $queries = [ - Query::equal('userId', $userId), + Query::equal('actorId', $userId), ]; if ($after !== null && $before !== null) { @@ -1934,7 +1988,7 @@ public function countByUser( ?int $max = null, ): int { $queries = [ - Query::equal('userId', $userId), + Query::equal('actorId', $userId), ]; if ($after !== null && $before !== null) { @@ -2021,7 +2075,7 @@ public function getByUserAndEvents( bool $ascending = false, ): array { $queries = [ - Query::equal('userId', $userId), + Query::equal('actorId', $userId), Query::contains('event', $events), ]; @@ -2053,7 +2107,7 @@ public function countByUserAndEvents( ?int $max = null, ): int { $queries = [ - Query::equal('userId', $userId), + Query::equal('actorId', $userId), Query::contains('event', $events), ]; From b3b663c9cad0c519c6505907058ea9a28fb331b0 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 18 May 2026 04:08:13 +0000 Subject: [PATCH 2/4] feat(log): add getActorId/getActorType/getActorInternalId getters Co-Authored-By: Claude Opus 4.7 (1M context) --- src/Audit/Log.php | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/src/Audit/Log.php b/src/Audit/Log.php index ffbf197..41d9f98 100644 --- a/src/Audit/Log.php +++ b/src/Audit/Log.php @@ -46,6 +46,39 @@ public function getUserId(): ?string return is_string($userId) ? $userId : null; } + /** + * Get the actor ID associated with this log entry. + * + * @return string|null + */ + public function getActorId(): ?string + { + $actorId = $this->getAttribute('actorId'); + return is_string($actorId) ? $actorId : null; + } + + /** + * Get the actor type associated with this log entry. + * + * @return string|null + */ + public function getActorType(): ?string + { + $actorType = $this->getAttribute('actorType'); + return is_string($actorType) ? $actorType : null; + } + + /** + * Get the actor internal ID associated with this log entry. + * + * @return string|null + */ + public function getActorInternalId(): ?string + { + $actorInternalId = $this->getAttribute('actorInternalId'); + return is_string($actorInternalId) ? $actorInternalId : null; + } + /** * Get the event name. * From 735b2a0d881d1a38c45b3fd2d3d952bcfe0ce841 Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 18 May 2026 04:08:17 +0000 Subject: [PATCH 3/4] test(clickhouse): assert actor attributes on ClickHouse-backed reads Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/Audit/Adapter/ClickHouseTest.php | 44 +++++++++++++------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/tests/Audit/Adapter/ClickHouseTest.php b/tests/Audit/Adapter/ClickHouseTest.php index b50da71..7cd4d85 100644 --- a/tests/Audit/Adapter/ClickHouseTest.php +++ b/tests/Audit/Adapter/ClickHouseTest.php @@ -53,7 +53,7 @@ protected function initializeAudit(): void protected function getRequiredAttributes(): array { return [ - 'userType' => 'member', + 'actorType' => 'member', 'resourceType' => 'document', 'resourceId' => 'res-1', 'projectId' => 'proj-1', @@ -413,7 +413,7 @@ public function testBatchOperationsWithSpecialCharacters(): void // Test batch with special characters in data $batchEvents = [ [ - 'userId' => 'user`with`backticks', + 'actorId' => 'actor`with`backticks', 'event' => 'create', 'resource' => 'doc/"quotes"', 'userAgent' => "User'Agent\"With'Quotes", @@ -429,7 +429,7 @@ public function testBatchOperationsWithSpecialCharacters(): void $this->assertTrue($result); // Verify retrieval - $logs = $this->audit->getLogsByUser('user`with`backticks'); + $logs = $this->audit->getLogsByUser('actor`with`backticks'); $this->assertGreaterThan(0, count($logs)); } @@ -449,9 +449,9 @@ public function testClickHouseAdapterAttributes(): void // Verify all expected attributes exist $expectedAttributes = [ - 'userType', - 'userId', - 'userInternalId', + 'actorType', + 'actorId', + 'actorInternalId', 'resourceParent', 'resourceType', 'resourceId', @@ -491,11 +491,11 @@ public function testClickHouseAdapterIndexes(): void // Verify all ClickHouse-specific indexes exist $expectedClickHouseIndexes = [ - '_key_user_internal_and_event', + '_key_actor_internal_and_event', '_key_project_internal_id', '_key_team_internal_id', - '_key_user_internal_id', - '_key_user_type', + '_key_actor_internal_id', + '_key_actor_type', '_key_country', '_key_hostname' ]; @@ -505,7 +505,7 @@ public function testClickHouseAdapterIndexes(): void } // Verify parent indexes are also included (with parent naming convention) - $parentExpectedIndexes = ['idx_event', 'idx_userId_event', 'idx_resource_event', 'idx_time_desc']; + $parentExpectedIndexes = ['idx_event', 'idx_actorId_event', 'idx_resource_event', 'idx_time_desc']; foreach ($parentExpectedIndexes as $expected) { $this->assertContains($expected, $indexIds, "Parent index '{$expected}' not found in ClickHouse adapter"); } @@ -516,7 +516,7 @@ public function testClickHouseAdapterIndexes(): void */ public function testParseResourceComplexPath(): void { - $userId = 'parseUser'; + $actorId = 'parseActor'; $userAgent = 'UnitTestAgent/1.0'; $ip = '127.0.0.1'; $location = 'US'; @@ -532,7 +532,7 @@ public function testParseResourceComplexPath(): void unset($required['resourceType'], $required['resourceId'], $required['resourceParent']); $dataWithAttributes = array_merge($data, $required); - $log = $this->audit->log($userId, 'create', $resource, $userAgent, $ip, $location, $dataWithAttributes); + $log = $this->audit->log($actorId, 'create', $resource, $userAgent, $ip, $location, $dataWithAttributes); $this->assertInstanceOf(\Utopia\Audit\Log::class, $log); @@ -652,7 +652,7 @@ public function testCountByUserWithMaxBound(): void public function testNotEqualQuery(): void { - // Fixture: 3x event=update/delete for userId, plus 1x event=insert for null user + // Fixture: 3x event=update/delete for actor, plus 1x event=insert for null actor $logs = $this->audit->find([ Query::notEqual('event', 'update'), ]); @@ -712,17 +712,17 @@ public function testNotBetweenQuery(): void public function testIsNullAndIsNotNullQueries(): void { - $nullUser = $this->audit->find([ - Query::isNull('userId'), + $nullActor = $this->audit->find([ + Query::isNull('actorId'), ]); - // Only the insert log has null userId - $this->assertCount(1, $nullUser); - $this->assertEquals('insert', $nullUser[0]->getEvent()); + // Only the insert log has null actorId + $this->assertCount(1, $nullActor); + $this->assertEquals('insert', $nullActor[0]->getEvent()); - $notNullUser = $this->audit->find([ - Query::isNotNull('userId'), + $notNullActor = $this->audit->find([ + Query::isNotNull('actorId'), ]); - $this->assertCount(3, $notNullUser); + $this->assertCount(3, $notNullActor); } public function testStartsWithAndEndsWithQueries(): void @@ -778,7 +778,7 @@ public function testSelectProjectsRequestedColumns(): void { $logs = $this->audit->find([ Query::select(['event', 'resource']), - Query::equal('userId', 'userId'), + Query::equal('actorId', 'userId'), Query::limit(1), ]); From 39f47b5caa016cdbc5830c1a0a2d5e8b5b4b3add Mon Sep 17 00:00:00 2001 From: Damodar Lohani Date: Mon, 18 May 2026 04:08:21 +0000 Subject: [PATCH 4/4] docs: changelog for 2.4.0 Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e6fa6a2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,51 @@ +# Changelog + +All notable changes to `utopia-php/audit` are documented in this file. + +## 2.4.0 + +### ClickHouse adapter — actor terminology + +The ClickHouse adapter now stores its principal columns under "actor" terminology: +`actorId`, `actorType`, `actorInternalId`. The shared SQL base, the Database adapter, +and the public `Audit` API are unchanged — Database-backed audit logs continue to use +`userId`. + +This is a non-breaking change for callers of the public API. `Audit::log($userId, ...)`, +`Audit::getLogsByUser(...)`, `Audit::countLogsByUser(...)`, and the equivalent +`*ByUserAndEvents` methods all keep their original signatures. The ClickHouse adapter +translates the legacy `userId` array key and `Query::equal('userId', ...)` filter +internally to the renamed `actorId` column. + +#### Added + +- `Log::getActorId()`, `Log::getActorType()`, `Log::getActorInternalId()` getters for + ClickHouse-backed log reads. +- `Log` instances returned by the ClickHouse adapter expose both `actorId` / `actorType` + / `actorInternalId` (canonical) and `userId` / `userType` / `userInternalId` (legacy + mirror) attribute keys so existing code paths continue to work. + +#### ClickHouse schema changes + +- Column `userId` → `actorId` +- Column `userType` → `actorType` +- Column `userInternalId` → `actorInternalId` +- Index `idx_userId_event` → `idx_actorId_event` +- Index `_key_user_type` → `_key_actor_type` +- Index `_key_user_internal_id` → `_key_actor_internal_id` +- Index `_key_user_internal_and_event` → `_key_actor_internal_and_event` + +#### Migration + +ClickHouse audit tables will be recreated by `setup()` with the new column names. +Existing ClickHouse audit data is not preserved automatically — this is acceptable +because the activity-events surface backed by this schema is not yet in public use. +If preservation is needed, run `ALTER TABLE ... RENAME COLUMN` for each renamed +column before redeploying. + +No migration is required for Database-backed audit logs. The Database adapter +continues to write and read `userId` columns and indexes unchanged. + +## 2.3.2 and earlier + +See git history.