refactor: extract the lit/content-create draft transaction into NewDraft (M7)

NewDraft owns insert (partial write on a throwing hook), refuse and discard (via
Rollback::attempt and WriteFailed::*) and the partial logging; ContentCreateAbility
keeps checkDivi/prepare/combined (Errors::fieldsOf) and the result. Both discard
callers still pass REASON_EXTRAS. Behaviour unchanged; NewDraftTest adds the
throwing-delete branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-26 17:43:50 +02:00
co-authored by Claude Opus 5.5
parent 4114bdb805
commit d2cc8497c9
3 changed files with 203 additions and 94 deletions
@@ -7,14 +7,11 @@ namespace LIT\MCP\Integrations\WordPress\Content;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\Rollback;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\WordPress\PostChangeResult;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use RuntimeException;
use Throwable;
use WP_Error;
use WP_Post;
@@ -25,6 +22,7 @@ use WP_Post;
* afterwards, created terms and the brand-new draft are deleted again and a WriteFailed
* is thrown (AbilityRegistrar → lit_write_failed, or lit_partial_write if that cleanup
* failed or the insert itself threw; partial writes are logged with result "partial").
* The insert and its rollbacks live in NewDraft; this class checks, orders and reports.
*
* Divi short form (spec §6.2): with `divi` the ContentComposer (Divi integration) checks the whole
* description before the insert; the draft is inserted with its normal WordPress fields only
@@ -33,9 +31,6 @@ use WP_Post;
*/
final class ContentCreateAbility implements Ability
{
private const REASON_INSERT = 'WordPress hat beim Anlegen des Eintrags einen Fehler gemeldet.';
private const REASON_EXTRAS = 'Felder, Begriffe oder Beitragsbild konnten nicht gespeichert werden.';
public function __construct(
private ContentSchema $schema,
private PostWriter $writer,
@@ -133,38 +128,31 @@ final class ContentCreateAbility implements Ability
'post_status' => 'draft',
'post_author' => get_current_user_id(),
]);
try {
$postId = wp_insert_post(wp_slash($data), true);
} catch (Throwable $e) {
// A hook threw – the row may or may not exist and its ID is unknown; nothing is
// searched or deleted, the client is told to check (lit_partial_write without id).
$this->logPartial($postType, '', 'Anlegen abgebrochen; Entwurf möglicherweise vorhanden');
throw WriteFailed::partial(self::REASON_INSERT, $e);
}
$draft = new NewDraft($this->writer, $this->log, $this->name(), $postType);
$postId = $draft->insert($data);
if ($postId instanceof WP_Error) {
return Errors::invalidInput('WordPress hat den Eintrag abgelehnt: ' . $postId->get_error_message());
return $postId;
}
$postId = (int) $postId;
$denied = $this->writer->deniedMeta($postId, $postType, $prepared['fields'], 'add_post_meta');
if ($denied !== []) {
return $this->refuse($postId, $postType, $denied);
return $draft->refuse($postId, $denied);
}
$layout = null;
try {
$layout = $withDivi ? $this->divi?->write($postId, $postType, self::sections($input)) : null;
} catch (Throwable $e) {
$this->discard($postId, $postType, $e, self::REASON_EXTRAS);
$draft->discard($postId, $e, NewDraft::REASON_EXTRAS);
}
if ($layout instanceof WP_Error) {
return $this->refuse($postId, $postType, $layout);
return $draft->refuse($postId, $layout);
}
try {
$this->writer->writeExtras($postId, $postType, $prepared, $input);
} catch (Throwable $e) {
$this->discard($postId, $postType, $e, self::REASON_EXTRAS);
$draft->discard($postId, $e, NewDraft::REASON_EXTRAS);
}
$warnings = array_merge($prepared['warnings'], $layout['warnings'] ?? []);
@@ -245,78 +233,4 @@ final class ContentCreateAbility implements Ability
return $result->toArray();
}
/**
* Rolls the brand-new draft back: terms it created, then the draft itself (force –
* it cannot have been used anywhere yet). $reason is the client text of the error.
*
* @throws WriteFailed Always.
*/
private function discard(int $postId, string $postType, Throwable $cause, string $reason): never
{
$problems = Rollback::attempt(
fn () => $this->writer->deleteCreatedTerms(),
static function () use ($postId): void {
try {
$deleted = wp_delete_post($postId, true);
} catch (Throwable $e) {
throw new RuntimeException(
sprintf('draft %d could not be deleted: %s', $postId, $e->getMessage()),
0,
$e
);
}
if (! $deleted) {
throw new RuntimeException(sprintf('draft %d could not be deleted', $postId));
}
}
);
$detail = sprintf('Extras of new post %d could not be written: %s', $postId, $cause->getMessage());
if ($problems === []) {
throw WriteFailed::rolledBack($reason, $cause, $detail);
}
$summary = sprintf('Entwurf %d: Anlegen nur teilweise zurückgenommen', $postId);
$this->logPartial($postType, (string) $postId, $summary);
throw WriteFailed::partial($reason, $cause, $detail . '; rollback: ' . implode('; ', $problems), $postId);
}
/**
* Some checks need the draft: meta rights (add_post_meta needs its ID) and the Divi layout
* against the new entry's own dynamic content options. A refusal removes the brand-new draft
* again and is reported like any invalid input.
*
* @param array<string, string>|WP_Error $denied fields.<key> => error, or the error itself.
* @throws WriteFailed When the draft cannot be deleted.
*/
private function refuse(int $postId, string $postType, array|WP_Error $denied): WP_Error
{
try {
$deleted = (bool) wp_delete_post($postId, true);
} catch (Throwable $e) {
$deleted = false;
}
if (! $deleted) {
$summary = sprintf('Entwurf %d: Anlegen nicht zurückgenommen', $postId);
$this->logPartial($postType, (string) $postId, $summary);
$fields = $denied instanceof WP_Error ? Errors::fieldsOf($denied) : $denied;
throw WriteFailed::partial(
($denied instanceof WP_Error ? 'Ungültiger Divi-Inhalt: ' : 'Keine Berechtigung für ')
. implode(', ', array_keys($fields)) . '.',
detail: sprintf('New post %d refused after the insert; draft could not be deleted', $postId),
postId: $postId
);
}
return $denied instanceof WP_Error
? $denied
: Errors::invalidInput('Eingaben sind ungültig; nichts wurde gespeichert.', $denied);
}
private function logPartial(string $postType, string $postId, string $summary): void
{
$this->log->recordPartial($this->name(), $postType, $postId, $summary);
}
}