refactor: ChangeLog::recordPartial and column-size truncation
recordPartial() replaces the partial-log FollowUp blocks in content-create, content-update and post-replace-with-copy; record() truncates ability, object_type and object_id to their column sizes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace LIT\MCP\ChangeLog;
|
namespace LIT\MCP\ChangeLog;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\FollowUp;
|
||||||
use LIT\MCP\Server\RequestContext;
|
use LIT\MCP\Server\RequestContext;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -44,9 +45,9 @@ class ChangeLog
|
|||||||
'created_at' => current_time('mysql', true),
|
'created_at' => current_time('mysql', true),
|
||||||
'user_id' => get_current_user_id(),
|
'user_id' => get_current_user_id(),
|
||||||
'client' => $this->sanitizedClientName(),
|
'client' => $this->sanitizedClientName(),
|
||||||
'ability' => $ability,
|
'ability' => mb_substr($ability, 0, 100),
|
||||||
'object_type' => $objectType,
|
'object_type' => mb_substr($objectType, 0, 50),
|
||||||
'object_id' => $objectId,
|
'object_id' => mb_substr($objectId, 0, 191),
|
||||||
'summary' => mb_substr($summary, 0, 500),
|
'summary' => mb_substr($summary, 0, 500),
|
||||||
'revision_id' => $revisionId,
|
'revision_id' => $revisionId,
|
||||||
'snapshot_id' => $snapshotId,
|
'snapshot_id' => $snapshotId,
|
||||||
@@ -64,6 +65,27 @@ class ChangeLog
|
|||||||
return (int) $wpdb->insert_id;
|
return (int) $wpdb->insert_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs a write that failed and could not be rolled back completely (result "partial").
|
||||||
|
* Never throws: the caller is already reporting an error, a log failure only goes to
|
||||||
|
* the PHP error log.
|
||||||
|
*/
|
||||||
|
public function recordPartial(
|
||||||
|
string $ability,
|
||||||
|
string $objectType,
|
||||||
|
string $objectId,
|
||||||
|
string $summary,
|
||||||
|
?int $revisionId = null
|
||||||
|
): void {
|
||||||
|
$ignored = [];
|
||||||
|
FollowUp::run(
|
||||||
|
fn () => $this->record($ability, $objectType, $objectId, $summary, $revisionId, null, 'partial'),
|
||||||
|
$ability . ' change log',
|
||||||
|
'',
|
||||||
|
$ignored
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function purgeOlderThan(int $days): int
|
public function purgeOlderThan(int $days): int
|
||||||
{
|
{
|
||||||
$wpdb = $this->db();
|
$wpdb = $this->db();
|
||||||
|
|||||||
@@ -204,12 +204,6 @@ final class ContentCreateAbility implements Ability
|
|||||||
|
|
||||||
private function logPartial(string $postType, string $postId, string $summary): void
|
private function logPartial(string $postType, string $postId, string $summary): void
|
||||||
{
|
{
|
||||||
$ignored = [];
|
$this->log->recordPartial($this->name(), $postType, $postId, $summary);
|
||||||
FollowUp::run(
|
|
||||||
fn () => $this->log->record($this->name(), $postType, $postId, $summary, null, null, 'partial'),
|
|
||||||
$this->name() . ' change log',
|
|
||||||
'',
|
|
||||||
$ignored
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -247,20 +247,12 @@ final class ContentUpdateAbility implements Ability
|
|||||||
throw new WriteFailed(self::REASON, true, $postId, $revisionId, $cause, $detail);
|
throw new WriteFailed(self::REASON, true, $postId, $revisionId, $cause, $detail);
|
||||||
}
|
}
|
||||||
|
|
||||||
$ignored = [];
|
$this->log->recordPartial(
|
||||||
FollowUp::run(
|
$this->name(),
|
||||||
fn () => $this->log->record(
|
(string) $post->post_type,
|
||||||
$this->name(),
|
(string) $postId,
|
||||||
(string) $post->post_type,
|
sprintf('"%s": Änderung nur teilweise zurückgenommen', $post->post_title),
|
||||||
(string) $postId,
|
$revisionId
|
||||||
sprintf('"%s": Änderung nur teilweise zurückgenommen', $post->post_title),
|
|
||||||
$revisionId,
|
|
||||||
null,
|
|
||||||
'partial'
|
|
||||||
),
|
|
||||||
$this->name() . ' change log',
|
|
||||||
'',
|
|
||||||
$ignored
|
|
||||||
);
|
);
|
||||||
throw new WriteFailed(
|
throw new WriteFailed(
|
||||||
self::REASON,
|
self::REASON,
|
||||||
|
|||||||
@@ -283,20 +283,12 @@ final class PostReplaceWithCopyAbility implements Ability
|
|||||||
try {
|
try {
|
||||||
$this->cloner->restore($original, $snapshot, $parts);
|
$this->cloner->restore($original, $snapshot, $parts);
|
||||||
} catch (Throwable $e) {
|
} catch (Throwable $e) {
|
||||||
$ignored = [];
|
$this->log->recordPartial(
|
||||||
FollowUp::run(
|
$this->name(),
|
||||||
fn () => $this->log->record(
|
(string) $original->post_type,
|
||||||
$this->name(),
|
(string) $originalId,
|
||||||
(string) $original->post_type,
|
sprintf('"%s": Austausch nur teilweise zurückgenommen', $original->post_title),
|
||||||
(string) $originalId,
|
$revisionId
|
||||||
sprintf('"%s": Austausch nur teilweise zurückgenommen', $original->post_title),
|
|
||||||
$revisionId,
|
|
||||||
null,
|
|
||||||
'partial'
|
|
||||||
),
|
|
||||||
$this->name() . ' change log',
|
|
||||||
'',
|
|
||||||
$ignored
|
|
||||||
);
|
);
|
||||||
throw new WriteFailed(
|
throw new WriteFailed(
|
||||||
self::REASON,
|
self::REASON,
|
||||||
|
|||||||
@@ -67,6 +67,53 @@ final class ChangeLogTest extends TestCase
|
|||||||
unlink($log);
|
unlink($log);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testRecordTruncatesObjectIdAndAbilityToTheColumnSizes(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_time')->justReturn('2026-09-25 08:00:00');
|
||||||
|
Functions\when('get_current_user_id')->justReturn(3);
|
||||||
|
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||||
|
$db->expects('insert')
|
||||||
|
->with('wp_lit_mcp_log', Mockery::on(static function (array $row): bool {
|
||||||
|
return $row['object_id'] === str_repeat('ä', 191) && $row['ability'] === str_repeat('ö', 100);
|
||||||
|
}), Mockery::type('array'))
|
||||||
|
->andReturn(1);
|
||||||
|
|
||||||
|
(new ChangeLog($db))->record(str_repeat('ö', 150), 'option', str_repeat('ä', 300), 'x');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRecordPartialStoresResultPartial(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_time')->justReturn('2026-09-25 08:00:00');
|
||||||
|
Functions\when('get_current_user_id')->justReturn(3);
|
||||||
|
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||||
|
$db->expects('insert')
|
||||||
|
->with('wp_lit_mcp_log', Mockery::on(static function (array $row): bool {
|
||||||
|
return $row['result'] === 'partial' && $row['revision_id'] === 7 && $row['object_id'] === '12';
|
||||||
|
}), Mockery::type('array'))
|
||||||
|
->andReturn(1);
|
||||||
|
|
||||||
|
(new ChangeLog($db))->recordPartial('lit/content-update', 'page', '12', 'teilweise', 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRecordPartialNeverThrows(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_time')->justReturn('2026-09-25 08:00:00');
|
||||||
|
Functions\when('get_current_user_id')->justReturn(3);
|
||||||
|
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||||
|
$db->allows('insert')->andThrow(new \RuntimeException('db gone'));
|
||||||
|
$log = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
|
||||||
|
$previous = ini_set('error_log', $log);
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new ChangeLog($db))->recordPartial('lit/menu-set-items', 'nav_menu', '4', 'teilweise');
|
||||||
|
} finally {
|
||||||
|
ini_set('error_log', (string) $previous);
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertStringContainsString('db gone', (string) file_get_contents($log));
|
||||||
|
unlink($log);
|
||||||
|
}
|
||||||
|
|
||||||
public function testPurgeDeletesRowsOlderThanTheRetention(): void
|
public function testPurgeDeletesRowsOlderThanTheRetention(): void
|
||||||
{
|
{
|
||||||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||||||
|
|||||||
@@ -134,8 +134,8 @@ final class ContentCreateAbilityTest extends TestCase
|
|||||||
Functions\when('wp_insert_post')->justReturn(42);
|
Functions\when('wp_insert_post')->justReturn(42);
|
||||||
Functions\when('wp_delete_post')->justReturn(false);
|
Functions\when('wp_delete_post')->justReturn(false);
|
||||||
$log = Mockery::mock(ChangeLog::class);
|
$log = Mockery::mock(ChangeLog::class);
|
||||||
$log->expects('record')
|
$log->expects('recordPartial')
|
||||||
->with('lit/content-create', 'lit_person', '42', Mockery::type('string'), null, null, 'partial')
|
->with('lit/content-create', 'lit_person', '42', Mockery::type('string'))
|
||||||
->andReturn(9);
|
->andReturn(9);
|
||||||
|
|
||||||
$failure = $this->execute($writer, $log);
|
$failure = $this->execute($writer, $log);
|
||||||
@@ -153,7 +153,7 @@ final class ContentCreateAbilityTest extends TestCase
|
|||||||
Functions\when('wp_insert_post')->justReturn(42);
|
Functions\when('wp_insert_post')->justReturn(42);
|
||||||
Functions\when('wp_delete_post')->justReturn(new WP_Post(['ID' => 42]));
|
Functions\when('wp_delete_post')->justReturn(new WP_Post(['ID' => 42]));
|
||||||
$log = Mockery::mock(ChangeLog::class);
|
$log = Mockery::mock(ChangeLog::class);
|
||||||
$log->expects('record')->andReturn(9);
|
$log->expects('recordPartial');
|
||||||
|
|
||||||
$failure = $this->execute($writer, $log);
|
$failure = $this->execute($writer, $log);
|
||||||
|
|
||||||
@@ -168,8 +168,8 @@ final class ContentCreateAbilityTest extends TestCase
|
|||||||
throw new RuntimeException('save_post hook kaputt');
|
throw new RuntimeException('save_post hook kaputt');
|
||||||
});
|
});
|
||||||
$log = Mockery::mock(ChangeLog::class);
|
$log = Mockery::mock(ChangeLog::class);
|
||||||
$log->expects('record')
|
$log->expects('recordPartial')
|
||||||
->with('lit/content-create', 'lit_person', '', Mockery::type('string'), null, null, 'partial')
|
->with('lit/content-create', 'lit_person', '', Mockery::type('string'))
|
||||||
->andReturn(9);
|
->andReturn(9);
|
||||||
|
|
||||||
$failure = $this->execute($writer, $log);
|
$failure = $this->execute($writer, $log);
|
||||||
|
|||||||
@@ -261,8 +261,8 @@ final class ContentUpdateAbilityTest extends TestCase
|
|||||||
$this->captureUpdates($updates);
|
$this->captureUpdates($updates);
|
||||||
Functions\when('wp_revisions_enabled')->justReturn(false);
|
Functions\when('wp_revisions_enabled')->justReturn(false);
|
||||||
$log = Mockery::mock(ChangeLog::class);
|
$log = Mockery::mock(ChangeLog::class);
|
||||||
$log->expects('record')
|
$log->expects('recordPartial')
|
||||||
->with('lit/content-update', 'page', '5', Mockery::type('string'), 811, null, 'partial')
|
->with('lit/content-update', 'page', '5', Mockery::type('string'), 811)
|
||||||
->andReturn(12);
|
->andReturn(12);
|
||||||
|
|
||||||
$failure = $this->executeExpectingFailure($writer, $input, $log);
|
$failure = $this->executeExpectingFailure($writer, $input, $log);
|
||||||
|
|||||||
@@ -228,8 +228,8 @@ final class PostReplaceWithCopyAbilityTest extends TestCase
|
|||||||
$cloner->expects('restore')->with($this->original, self::SNAPSHOT, PostCloner::PARTS)->andThrow(new RuntimeException('Restore incomplete: terms'));
|
$cloner->expects('restore')->with($this->original, self::SNAPSHOT, PostCloner::PARTS)->andThrow(new RuntimeException('Restore incomplete: terms'));
|
||||||
Functions\expect('wp_trash_post')->never();
|
Functions\expect('wp_trash_post')->never();
|
||||||
$log = Mockery::mock(ChangeLog::class);
|
$log = Mockery::mock(ChangeLog::class);
|
||||||
$log->expects('record')
|
$log->expects('recordPartial')
|
||||||
->with('lit/post-replace-with-copy', 'page', '5', Mockery::type('string'), 900, null, 'partial')
|
->with('lit/post-replace-with-copy', 'page', '5', Mockery::type('string'), 900)
|
||||||
->andReturn(12);
|
->andReturn(12);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user