feat: content-create and content-update with schema validation

All-or-nothing writes: validation (fields, terms, featured image, parent,
template) before any write; failed extras delete the new draft (create) or
restore revision, old columns, fields, terms and featured image (update).
content-update requires rev; Rev::ofPost covers content, title, excerpt,
status and post_modified_gmt. MetaFieldSource::write() now throws on failure.

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:
2026-09-25 23:01:48 +02:00
co-authored by Claude Opus 5.5
parent b9d46f3cec
commit 4f9a47fe32
15 changed files with 1355 additions and 9 deletions
@@ -4,8 +4,11 @@ declare(strict_types=1);
namespace LIT\MCP\Integrations\WordPress\Schema;
use RuntimeException;
/**
* Shared read/write via the post meta API.
* Shared read/write via the post meta API. write() throws when WordPress did not
* store a value, so callers can roll back (all or nothing, spec §8.2).
*/
abstract class MetaFieldSource implements FieldSource
{
@@ -19,20 +22,50 @@ abstract class MetaFieldSource implements FieldSource
foreach ($values as $key => $value) {
$key = (string) $key;
if ($value === null) {
delete_post_meta($postId, $key);
$this->delete($postId, $key);
continue;
}
$field = $definitions[$key] ?? null;
if ($field !== null && $field->multiple && is_array($value) && array_is_list($value)) {
delete_post_meta($postId, $key);
$this->delete($postId, $key);
foreach ($value as $item) {
add_post_meta($postId, $key, wp_slash($item));
if (add_post_meta($postId, $key, wp_slash($item)) === false) {
throw new RuntimeException(sprintf('Meta "%s" of post %d could not be added.', $key, $postId));
}
}
continue;
}
update_post_meta($postId, $key, wp_slash($value));
// update_post_meta() also returns false when the value is unchanged – only a
// stored value that still differs is a failure.
if (update_post_meta($postId, $key, wp_slash($value)) === false && ! $this->holds($postId, $key, $value)) {
throw new RuntimeException(sprintf('Meta "%s" of post %d could not be saved.', $key, $postId));
}
}
}
private function delete(int $postId, string $key): void
{
// delete_post_meta() returns false as well when there was nothing to delete.
if (delete_post_meta($postId, $key) === false && metadata_exists('post', $postId, $key)) {
throw new RuntimeException(sprintf('Meta "%s" of post %d could not be deleted.', $key, $postId));
}
}
private function holds(int $postId, string $key, mixed $value): bool
{
$stored = get_post_meta($postId, $key, false);
if (! is_array($stored) || count($stored) !== 1) {
return false;
}
$current = $stored[0];
if (is_scalar($current) && is_scalar($value)) {
// Meta is stored as text: 3 and "3", true and "1" are the same stored value.
return (string) $current === (string) $value;
}
return $current == $value; // phpcs:ignore Universal.Operators.StrictComparisons.LooseEqual
}
}