fix: typed write failures, template rollback, parent and term cleanup

WriteFailed maps to lit_write_failed / lit_partial_write (partial writes are
logged with result partial); old template read before the update; row marker
set before wp_update_post; created terms deleted on rollback; parent must be a
readable non-descendant of a hierarchical type; featured image must be
readable; snapshot keeps empty values; meta comparison after sanitize_meta;
follow-up failures (listener, change log) only add warnings.

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:11:18 +02:00
co-authored by Claude Opus 5.5
parent 4f9a47fe32
commit a9b2f2db71
18 changed files with 934 additions and 130 deletions
@@ -39,7 +39,8 @@ abstract class MetaFieldSource implements FieldSource
// 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)) {
$updated = update_post_meta($postId, $key, wp_slash($value));
if ($updated === false && ! $this->holds($postId, $postType, $key, $value)) {
throw new RuntimeException(sprintf('Meta "%s" of post %d could not be saved.', $key, $postId));
}
}
@@ -53,8 +54,14 @@ abstract class MetaFieldSource implements FieldSource
}
}
private function holds(int $postId, string $key, mixed $value): bool
/**
* Whether the stored value already is what update_post_meta() would have stored –
* compared after sanitize_meta(), like WordPress does, so registered sanitize
* callbacks do not look like failures.
*/
private function holds(int $postId, string $postType, string $key, mixed $value): bool
{
$value = sanitize_meta($key, $value, 'post', $postType);
$stored = get_post_meta($postId, $key, false);
if (! is_array($stored) || count($stored) !== 1) {
return false;
@@ -49,31 +49,69 @@ final class TermAssigner
}
/**
* Replaces the post's terms per taxonomy, creating terms given by name. If this
* fails, the terms it created in this call are deleted again before it throws.
*
* @param array<string, list<int|string>> $resolved Output of resolve().
* @return array<int, string> Created terms: term ID => taxonomy (for a later rollback).
* @throws RuntimeException
*/
public function assign(int $postId, array $resolved): void
public function assign(int $postId, array $resolved): array
{
foreach ($resolved as $taxonomy => $items) {
$ids = [];
foreach ($items as $item) {
if (is_int($item)) {
$ids[] = $item;
continue;
$created = [];
try {
foreach ($resolved as $taxonomy => $items) {
$ids = [];
foreach ($items as $item) {
if (is_int($item)) {
$ids[] = $item;
continue;
}
$term = wp_insert_term($item, $taxonomy);
if (is_wp_error($term)) {
throw new RuntimeException('Term could not be created: ' . $term->get_error_message());
}
$ids[] = (int) $term['term_id'];
$created[(int) $term['term_id']] = $taxonomy;
}
$created = wp_insert_term($item, $taxonomy);
if (is_wp_error($created)) {
throw new RuntimeException('Term could not be created: ' . $created->get_error_message());
$result = wp_set_object_terms($postId, $ids, $taxonomy, false);
if (is_wp_error($result)) {
throw new RuntimeException('Terms could not be assigned: ' . $result->get_error_message());
}
$ids[] = (int) $created['term_id'];
}
} catch (RuntimeException $e) {
try {
$this->deleteTerms($created);
} catch (RuntimeException $cleanup) {
throw new RuntimeException($e->getMessage() . '; ' . $cleanup->getMessage(), 0, $e);
}
throw $e;
}
$result = wp_set_object_terms($postId, $ids, $taxonomy, false);
if (is_wp_error($result)) {
throw new RuntimeException('Terms could not be assigned: ' . $result->get_error_message());
return $created;
}
/**
* Deletes terms (e.g. the ones assign() created). A term that no longer exists is fine.
*
* @param array<int, string> $terms Term ID => taxonomy.
* @throws RuntimeException Naming every term that could not be deleted.
*/
public function deleteTerms(array $terms): void
{
$failed = [];
foreach ($terms as $termId => $taxonomy) {
$result = wp_delete_term((int) $termId, $taxonomy);
if ($result !== true && $result !== false) {
$failed[] = sprintf('%s %d', $taxonomy, $termId);
}
}
if ($failed !== []) {
throw new RuntimeException('Created terms could not be deleted: ' . implode(', ', $failed));
}
}
/**