Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.75% covered (warning)
86.75%
334 / 385
64.10% covered (warning)
64.10%
25 / 39
CRAP
0.00% covered (danger)
0.00%
0 / 1
Store
86.75% covered (warning)
86.75%
334 / 385
64.10% covered (warning)
64.10%
25 / 39
164.49
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
1
 save
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
2
 publish
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 draft
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
5
 discard
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
 unpublish
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 publishDraft
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 lock
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 holdsDrafts
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 workingData
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 submittedPaths
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 prepare
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 event
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 transaction
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 nodeId
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 create
83.33% covered (warning)
83.33%
20 / 24
0.00% covered (danger)
0.00%
0 / 1
9.37
 delete
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
6
 subtreeUids
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
5.01
 childUids
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 children
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 validate
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 persist
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 materializeTitle
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
5.07
 dynamicTitle
68.75% covered (warning)
68.75%
11 / 16
0.00% covered (danger)
0.00%
0 / 1
3.27
 titleEvalNode
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
5.12
 persistNode
92.31% covered (success)
92.31%
36 / 39
0.00% covered (danger)
0.00%
0 / 1
3.00
 persistHandle
59.09% covered (warning)
59.09%
13 / 22
0.00% covered (danger)
0.00%
0 / 1
6.71
 resolveParentUid
69.23% covered (warning)
69.23%
9 / 13
0.00% covered (danger)
0.00%
0 / 1
5.73
 resolveParentId
72.73% covered (warning)
72.73%
8 / 11
0.00% covered (danger)
0.00%
0 / 1
3.18
 assertUidUnchanged
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 normalizeSubmittedHandle
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 completeHandle
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 resolveHandle
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 ensureRouteHandle
50.00% covered (danger)
50.00%
3 / 6
0.00% covered (danger)
0.00%
0 / 1
4.12
 completeGeneratedPaths
56.25% covered (warning)
56.25%
9 / 16
0.00% covered (danger)
0.00%
0 / 1
3.75
 needsGeneratedPaths
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
4
 routeNeedsHandle
25.00% covered (danger)
25.00%
2 / 8
0.00% covered (danger)
0.00%
0 / 1
21.19
 routeContainsHandle
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ensureTypeExists
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2
3declare(strict_types=1);
4
5namespace Cosray\Node;
6
7use Celema\Core\Exception\HttpBadRequest;
8use Celema\Core\Exception\HttpConflict;
9use Celema\Core\Exception\HttpError;
10use Celema\Quma\Database;
11use Cosray\Actor;
12use Cosray\Block\Registry as Blocks;
13use Cosray\Cms;
14use Cosray\Context;
15use Cosray\Exception\NodeLocked;
16use Cosray\Exception\RoutePathError;
17use Cosray\Exception\RuntimeException;
18use Cosray\Fulltext;
19use Cosray\Locale;
20use Cosray\Locales;
21use Cosray\References;
22use Cosray\Richtext\Normalizer;
23use Cosray\Title\Resolver as TitleResolver;
24use Cosray\Uid;
25use Cosray\Util\Transaction;
26use Cosray\Validation\ValidatorFactory;
27use Cosray\Webhooks;
28use DateTimeInterface;
29use Throwable;
30
31class Store
32{
33    private const int CREATE_UID_ATTEMPTS = 5;
34
35    private readonly RoutePathGenerator $routePathGenerator;
36    private readonly References\Scanner $scanner;
37    private readonly References\Sync $sync;
38    private readonly TitleResolver $titleResolver;
39    private readonly Drafts $drafts;
40    private readonly Fulltext\Sync $fulltext;
41
42    public function __construct(
43        private readonly Database $db,
44        private readonly PathManager $pathManager,
45        private readonly Types $types,
46        private readonly Uid $uid,
47        ?RoutePathGenerator $routePathGenerator = null,
48        // Dynamic (Contract\Title) titles read the node's fields, so they must
49        // be re-hydrated from the content being saved. These stay optional so
50        // a bare Store still works; without them dynamic titles fall back to
51        // the passed node and `db:titles` is the authoritative refresh.
52        private readonly ?Factory $factory = null,
53        private readonly ?Cms $cms = null,
54        private readonly ?Context $context = null,
55    ) {
56        $this->routePathGenerator = $routePathGenerator ?? new RoutePathGenerator($db, $types);
57        $this->scanner = new References\Scanner();
58        $this->sync = new References\Sync($db);
59        $this->titleResolver = new TitleResolver($types);
60        $this->drafts = new Drafts($db);
61        $this->fulltext = new Fulltext\Sync($db, new Fulltext\Builder(
62            $types,
63            $factory?->hydrator()->services()->blocks ?? Blocks::withDefaults(),
64        ));
65    }
66
67    /**
68     * Writes the live row. A pending working copy is left alone: this is
69     * the API path, and the editor's publish() is what supersedes a draft.
70     *
71     * @param array<string, mixed> $data
72     * @return array{success: true, uid: string}
73     */
74    public function save(
75        object $node,
76        array $data,
77        Locales $locales,
78        Actor $actor,
79        bool $create = false,
80    ): array {
81        $this->fulltext->validate($locales);
82        $data = $this->prepare($node, $data, $locales, $create);
83
84        $this->transaction(function () use ($node, $data, $actor, $locales, $create): void {
85            $this->lock($data['uid']);
86            $this->persist($node, $data, $actor->id, $locales, $create);
87            $this->event($create ? 'node.created' : 'node.updated', $node, $data['uid']);
88        }, 'Error while saving: ');
89
90        return ['success' => true, 'uid' => $data['uid']];
91    }
92
93    /**
94     * Writes the live row and drops the working copy it supersedes.
95     *
96     * @param array<string, mixed> $data
97     * @return array{success: true, uid: string}
98     */
99    public function publish(object $node, array $data, Locales $locales, Actor $actor): array
100    {
101        $this->fulltext->validate($locales);
102        $data = $this->prepare($node, $data, $locales);
103        $nodeId = $this->nodeId($node);
104
105        $this->transaction(function () use ($node, $data, $locales, $actor, $nodeId): void {
106            $this->lock($data['uid']);
107            $this->persist($node, $data, $actor->id, $locales);
108            $this->drafts->delete($nodeId, published: true);
109            $this->sync->remove('draft', $data['uid']);
110            $this->event('node.updated', $node, $data['uid']);
111        }, 'Error while publishing: ');
112
113        return ['success' => true, 'uid' => $data['uid']];
114    }
115
116    /**
117     * Saves the working copy of a published node. Content, handle and
118     * paths wait for publish(); `hidden` is a live switch and applies now.
119     * A working copy that equals the live node is dropped, not saved.
120     *
121     * @param array<string, mixed> $data
122     * @return array{success: true, uid: string}
123     */
124    public function draft(object $node, array $data, Locales $locales, Actor $actor): array
125    {
126        if (!$this->holdsDrafts($node)) {
127            throw new RuntimeException('Only a published, renderable node can hold a working copy');
128        }
129
130        $data = $this->prepare($node, $data, $locales);
131        $paths = $this->submittedPaths($data);
132        // Refused now rather than when publish() applies the paths.
133        $this->pathManager->reserved->assertFree($paths);
134        $nodeId = $this->nodeId($node);
135        $content = is_array($data['content'] ?? null) ? $data['content'] : [];
136        $handle = $this->resolveHandle($data);
137
138        $this->transaction(function () use ($node, $data, $actor, $nodeId, $content, $handle, $paths): void {
139            $this->lock($data['uid']);
140
141            if ($this->drafts->matchesLive($nodeId, $content, $handle, $paths)) {
142                $this->drafts->delete($nodeId);
143                $this->sync->remove('draft', $data['uid']);
144            } else {
145                $this->drafts->save($nodeId, $content, ['handle' => $handle, 'paths' => $paths], $actor->id);
146                $this->sync->replace('draft', $data['uid'], $this->scanner->scan($content));
147            }
148
149            if ($data['hidden'] !== (bool) Factory::meta($node, 'hidden')) {
150                $this->db->nodes->setHidden([
151                    'node' => $nodeId,
152                    'hidden' => $data['hidden'],
153                    'editor' => $actor->id,
154                ])->run();
155            }
156
157            $this->event('node.draft', $node, $data['uid']);
158        }, 'Error while saving the working copy: ');
159
160        return ['success' => true, 'uid' => $data['uid']];
161    }
162
163    public function discard(object $node): void
164    {
165        $nodeId = $this->nodeId($node);
166        $uid = (string) Factory::meta($node, 'uid');
167
168        $this->transaction(function () use ($node, $nodeId, $uid): void {
169            $this->lock($uid);
170            $this->drafts->delete($nodeId);
171            $this->sync->remove('draft', $uid);
172            $this->event('node.draft', $node, $uid);
173        }, 'Error while discarding: ');
174    }
175
176    /**
177     * Takes the node offline. A working copy becomes the node content:
178     * with nothing live left to protect, the node is edited directly again.
179     */
180    public function unpublish(object $node, Locales $locales, Actor $actor): void
181    {
182        $draft = $this->drafts->get($this->nodeId($node));
183
184        if ($draft === null) {
185            $uid = (string) Factory::meta($node, 'uid');
186            $this->transaction(function () use ($node, $uid, $actor): void {
187                $this->lock($uid);
188                $this->db->nodes->setPublished(['uid' => $uid, 'published' => false, 'editor' => $actor->id])->run();
189                $this->event('node.updated', $node, $uid);
190            }, 'Error while unpublishing: ');
191
192            return;
193        }
194
195        $this->publish($node, $this->workingData($node, $draft, published: false), $locales, $actor);
196    }
197
198    /** Publishes the working copy, if there is one. */
199    public function publishDraft(object $node, Locales $locales, Actor $actor): bool
200    {
201        $draft = $this->drafts->get($this->nodeId($node));
202
203        if ($draft === null) {
204            return false;
205        }
206
207        $this->publish($node, $this->workingData($node, $draft, published: true), $locales, $actor);
208
209        return true;
210    }
211
212    /**
213     * Holds the node until the transaction ends, so writers of one node take
214     * turns. The uid lock also covers a node still to be created; the row
215     * lock also stops writers that update the row without this store.
216     */
217    public function lock(string $uid): void
218    {
219        $this->db->nodes->lock(['uid' => $uid])->run();
220        $this->db->nodes->lockRow(['uid' => $uid])->run();
221    }
222
223    public function holdsDrafts(object $node): bool
224    {
225        return (
226            (bool) $this->types->get($node::class, 'renderable', false)
227                && (bool) Factory::meta($node, 'published')
228                && Factory::meta($node, 'deleted') === null
229        );
230    }
231
232    /**
233     * The save payload for a node's working copy: the drafted content,
234     * handle and paths over the live flags.
235     *
236     * @param array{content: array<string, mixed>, settings: array<string, mixed>} $draft
237     * @return array<string, mixed>
238     */
239    private function workingData(object $node, array $draft, bool $published): array
240    {
241        $settings = $draft['settings'];
242        $paths = $settings['paths'] ?? Factory::meta($node, 'paths');
243
244        return [
245            'uid' => (string) Factory::meta($node, 'uid'),
246            'handle' => array_key_exists('handle', $settings)
247                ? $settings['handle']
248                : Factory::meta($node, 'handle'),
249            'published' => $published,
250            'hidden' => (bool) Factory::meta($node, 'hidden'),
251            'locked' => (bool) Factory::meta($node, 'locked'),
252            'paths' => is_array($paths) ? $paths : [],
253            'content' => $draft['content'],
254        ];
255    }
256
257    /**
258     * @param array<string, mixed> $data
259     * @return array<string, string>
260     */
261    private function submittedPaths(array $data): array
262    {
263        $paths = [];
264
265        foreach (is_array($data['paths'] ?? null) ? $data['paths'] : [] as $locale => $path) {
266            if (is_string($locale) && is_string($path)) {
267                $paths[$locale] = $path;
268            }
269        }
270
271        return $paths;
272    }
273
274    /**
275     * @param array<string, mixed> $data
276     * @return array<mixed>
277     */
278    private function prepare(object $node, array $data, Locales $locales, bool $create = false): array
279    {
280        $data = $this->normalizeSubmittedHandle($data);
281        $data = $this->validate($node, $data, $locales);
282
283        if (is_array($data['content'] ?? null)) {
284            // Richtext documents persist in canonical form (byte-stable
285            // storage for history diffs); empty documents become null.
286            $data['content'] = new Normalizer()->content($data['content']);
287        }
288
289        if (!$create) {
290            $this->assertUidUnchanged($node, $data);
291        }
292
293        $data = $this->completeHandle($node, $data);
294
295        if ($data['locked']) {
296            throw new HttpBadRequest(payload: ['message' => __('node:locked')]);
297        }
298
299        return $data;
300    }
301
302    /**
303     * Queues the webhook deliveries of a write inside its transaction, so
304     * they commit or roll back with it. A descendant deleted with its parent
305     * is reported with the parent's type.
306     */
307    private function event(string $event, object $node, string $uid): void
308    {
309        $container = $this->context?->container;
310        $webhooks = $container !== null && $container->has(Webhooks::class) ? $container->get(Webhooks::class) : null;
311
312        if ($webhooks instanceof Webhooks) {
313            $webhooks->record($event, ['uid' => $uid, 'type' => (string) $this->types->get($node::class, 'handle')]);
314        }
315    }
316
317    private function transaction(callable $work, string $failure): void
318    {
319        try {
320            Transaction::run($this->db, $work);
321        } catch (Throwable $e) {
322            if ($e instanceof HttpError) {
323                throw $e;
324            }
325
326            throw new RuntimeException(
327                $failure . $e->getMessage(),
328                (int) $e->getCode(),
329                previous: $e,
330            );
331        }
332    }
333
334    private function nodeId(object $node): int
335    {
336        $nodeId = Factory::meta($node, 'node');
337
338        if (!is_int($nodeId) && !is_string($nodeId)) {
339            throw new RuntimeException('Missing node id for update');
340        }
341
342        return (int) $nodeId;
343    }
344
345    /**
346     * @param array<string, mixed> $data
347     * @return array{success: true, uid: string}
348     */
349    public function create(
350        object $node,
351        array $data,
352        Locales $locales,
353        Actor $actor,
354        ?Actor $creator = null,
355        ?DateTimeInterface $created = null,
356        ?DateTimeInterface $changed = null,
357    ): array {
358        $this->fulltext->validate($locales);
359        $metadata = [
360            'creator' => ($creator ?? $actor)->id,
361            'created' => $created?->format('Y-m-d\TH:i:s.uP'),
362            'changed' => $changed?->format('Y-m-d\TH:i:s.uP'),
363        ];
364        $generatedUid = !array_key_exists('uid', $data);
365        if ($generatedUid) {
366            $data['uid'] = Factory::meta($node, 'uid') ?? $this->uid->generate();
367        }
368        $attempts = $generatedUid ? self::CREATE_UID_ATTEMPTS : 1;
369
370        for ($attempt = 1; $attempt <= $attempts; $attempt++) {
371            if ($generatedUid && $attempt > 1) {
372                $data['uid'] = $this->uid->generate();
373            }
374
375            try {
376                $prepared = $this->prepare($node, $data, $locales, create: true);
377                $this->transaction(function () use ($node, $prepared, $actor, $locales, $metadata): void {
378                    $this->lock($prepared['uid']);
379                    $this->persist($node, $prepared, $actor->id, $locales, create: true, metadata: $metadata);
380                    $this->event('node.created', $node, $prepared['uid']);
381                }, 'Error while saving: ');
382
383                return ['success' => true, 'uid' => $prepared['uid']];
384            } catch (HttpConflict $e) {
385                if (!$generatedUid || $attempt === $attempts) {
386                    throw $e;
387                }
388            }
389        }
390
391        throw new RuntimeException('Could not generate a unique node uid');
392    }
393
394    /**
395     * A locked node is refused, and so is a subtree holding one.
396     *
397     * @return array{success: true, error: false, deleted: list<string>}
398     */
399    public function delete(object $node, Actor $actor, bool $withChildren = false): array
400    {
401        $uid = (string) Factory::meta($node, 'uid');
402
403        if ((bool) Factory::meta($node, 'locked')) {
404            throw new NodeLocked(payload: ['message' => __('node:locked')]);
405        }
406
407        if (!$withChildren && $this->childUids($uid) !== []) {
408            throw new HttpConflict(payload: ['message' => __('node:has-children')]);
409        }
410
411        $uids = $withChildren ? $this->subtreeUids($uid) : [$uid];
412
413        $this->transaction(function () use ($node, $uid, $uids, $actor): void {
414            $this->lock($uid);
415
416            foreach ($uids as $deleteUid) {
417                $this->db->nodes->delete([
418                    'uid' => $deleteUid,
419                    'editor' => $actor->id,
420                ])->run();
421                $this->event('node.deleted', $node, $deleteUid);
422            }
423        }, 'Error while deleting: ');
424
425        return [
426            'success' => true,
427            'error' => false,
428            'deleted' => $uids,
429        ];
430    }
431
432    /**
433     * The node and all its non-deleted descendants, children before
434     * parents so a partial failure never leaves a reachable orphan.
435     *
436     * @return list<string>
437     */
438    private function subtreeUids(string $uid): array
439    {
440        $uids = [$uid];
441        $queue = [$uid];
442
443        while ($queue !== []) {
444            $current = array_shift($queue);
445
446            foreach ($this->children($current) as $child) {
447                // A parent cycle would loop forever; unseen uids only.
448                if (in_array($child['uid'], $uids, true)) {
449                    continue;
450                }
451
452                if ($child['locked']) {
453                    throw new NodeLocked(payload: ['message' => __('node:locked-descendant')]);
454                }
455
456                $uids[] = $child['uid'];
457                $queue[] = $child['uid'];
458            }
459        }
460
461        return array_reverse($uids);
462    }
463
464    /** @return list<string> */
465    private function childUids(string $uid): array
466    {
467        return array_column($this->children($uid), 'uid');
468    }
469
470    /** @return list<array{uid: string, locked: bool}> */
471    private function children(string $uid): array
472    {
473        return array_map(
474            static fn(array $row): array => ['uid' => (string) $row['uid'], 'locked' => (bool) $row['locked']],
475            $this->db->nodes->childUids(['uid' => $uid])->all(),
476        );
477    }
478
479    /**
480     * @param array<string, mixed> $data
481     * @return array<mixed>
482     */
483    public function validate(object $node, array $data, Locales $locales): array
484    {
485        $factory = new ValidatorFactory($node, $locales);
486        $shape = $factory->create();
487        $result = $shape->validate($data);
488
489        if (!$result->valid()) {
490            throw new HttpBadRequest(payload: [
491                'message' => __('node:invalid-data'),
492                'errors' => $result->issues(),
493            ]);
494        }
495
496        return $result->values();
497    }
498
499    /**
500     * @param array{creator?: int, created?: ?string, changed?: ?string} $metadata
501     * @param array<mixed> $data
502     */
503    private function persist(
504        object $node,
505        array $data,
506        int $editor,
507        Locales $locales,
508        bool $create = false,
509        array $metadata = [],
510    ): void {
511        $parentUid = $this->resolveParentUid($node, $data);
512        $parentId = $this->resolveParentId($parentUid);
513        $handle = $this->resolveHandle($data);
514
515        // Materialize the title alongside the content so both ride one UPDATE
516        // (a single change/history record instead of two).
517        $data['title'] = $this->materializeTitle($node, $data, $locales);
518
519        $nodeId = $this->persistNode($node, $data, $editor, $parentId, $create, $metadata);
520        $this->persistHandle($nodeId, $handle, $editor);
521
522        if (!$create) {
523            $this->db->positions->leaveParent(['node' => $nodeId, 'parent' => $parentId])->run();
524        }
525
526        // The reference indexes ride in the save transaction: full
527        // replace per owner from the content just written.
528        $this->sync->replace('node', $data['uid'], $this->scanner->scan($data['content'] ?? []));
529        $this->fulltext->replace($nodeId, $data['uid'], $node::class, $data['content'] ?? [], $data['title'], $locales);
530
531        if ((bool) $this->types->get($node::class, 'routable', false)) {
532            $this->ensureRouteHandle($node, $handle);
533            $data = $this->completeGeneratedPaths($node, $data, $locales, $parentId);
534            $this->pathManager->persist($this->db, $data, $editor, $nodeId, $locales);
535        }
536    }
537
538    /**
539     * The materialized title map for the content being saved. Mirrors
540     * `Wrapper::title()`'s resolution but yields every locale.
541     *
542     * @param array<mixed> $data
543     * @return array<string, string>
544     */
545    private function materializeTitle(
546        object $node,
547        array $data,
548        Locales $locales,
549    ): array {
550        $content = is_array($data['content'] ?? null) ? $data['content'] : [];
551        $descriptor = $this->titleResolver->descriptor($node::class);
552
553        return match ($descriptor['kind']) {
554            TitleResolver::KIND_FIELD => $this->titleResolver->fieldMap($content, $descriptor['field']),
555            TitleResolver::KIND_DYNAMIC => $this->dynamicTitle($node, $data, $locales),
556            default => [],
557        };
558    }
559
560    /**
561     * Evaluate a dynamic title once per locale against the content being
562     * saved, restoring the request locale afterwards.
563     *
564     * @param array<mixed> $data
565     * @return array<string, string>
566     */
567    private function dynamicTitle(object $node, array $data, Locales $locales): array
568    {
569        $target = $this->titleEvalNode($node, $data);
570        $provider = $this->titleResolver->provider($target);
571
572        if ($provider === null) {
573            return [];
574        }
575
576        if ($this->context === null) {
577            return $this->titleResolver->dynamicMap(
578                static fn(Locale $locale): string => $provider->title(),
579                $locales,
580            );
581        }
582
583        return $this->titleResolver->dynamicMap(
584            fn(Locale $locale): string => $this->context->withLocale(
585                $locale,
586                $provider->title(...),
587            ),
588            $locales,
589        );
590    }
591
592    /**
593     * A node instance whose fields reflect the content being saved. When the
594     * factory seam is wired the node is re-hydrated from that content (the
595     * passed instance still carries the previous version); otherwise the
596     * passed node is used and `db:titles` remains the authoritative refresh.
597     *
598     * @param array<mixed> $data
599     */
600    private function titleEvalNode(object $node, array $data): object
601    {
602        if ($this->factory !== null && $this->cms !== null && $this->context !== null) {
603            return $this->factory->create($node::class, $this->context, $this->cms, [
604                'uid' => $data['uid'],
605                'content' => is_array($data['content'] ?? null) ? $data['content'] : [],
606            ]);
607        }
608
609        return $node;
610    }
611
612    /**
613     * @param array{creator?: int, created?: ?string, changed?: ?string} $metadata
614     * @param array<mixed> $data
615     */
616    private function persistNode(
617        object $node,
618        array $data,
619        int $editor,
620        ?int $parent,
621        bool $create,
622        array $metadata,
623    ): int {
624        $class = $node::class;
625        $handle = (string) $this->types->get($class, 'handle');
626        $this->ensureTypeExists($handle);
627        $params = [
628            'uid' => $data['uid'],
629            'parent' => $parent,
630            'hidden' => $data['hidden'],
631            'published' => $data['published'],
632            'locked' => $data['locked'],
633            'type' => $handle,
634            'content' => json_encode($data['content']),
635            'title' => json_encode($data['title'] ?? []),
636            'editor' => $editor,
637        ];
638
639        if (!$create) {
640            return (int) $this->db->nodes->save([
641                'node' => $this->nodeId($node),
642                'parent' => $params['parent'],
643                'hidden' => $params['hidden'],
644                'published' => $params['published'],
645                'locked' => $params['locked'],
646                'content' => $params['content'],
647                'title' => $params['title'],
648                'editor' => $params['editor'],
649            ])->one()['node'];
650        }
651
652        $result = $this->db
653            ->nodes
654            ->create([
655                ...$params,
656                'creator' => $metadata['creator'] ?? $editor,
657                'created' => $metadata['created'] ?? null,
658                'changed' => $metadata['changed'] ?? null,
659            ])
660            ->first();
661
662        if (!$result) {
663            throw new HttpConflict(payload: [
664                'message' => __('node:duplicate-uid', ['uid' => $data['uid']]),
665            ]);
666        }
667
668        return (int) $result['node'];
669    }
670
671    private function persistHandle(int $nodeId, ?string $handle, int $editor): void
672    {
673        if ($handle === null) {
674            $this->db->nodes->deleteHandle(['node' => $nodeId])->run();
675
676            return;
677        }
678
679        $collision = $this->db
680            ->nodes
681            ->handleUidCollision(['handle' => $handle, 'node' => $nodeId])
682            ->first();
683
684        if ($collision) {
685            throw new HttpConflict(payload: [
686                'message' => __('node:duplicate-handle-uid', ['handle' => $handle]),
687            ]);
688        }
689
690        try {
691            $this->db->nodes->saveHandle([
692                'node' => $nodeId,
693                'handle' => $handle,
694                'editor' => $editor,
695            ])->run();
696        } catch (Throwable $e) {
697            if ((string) $e->getCode() === '23505') {
698                throw new HttpConflict(payload: [
699                    'message' => __('node:duplicate-handle', ['handle' => $handle]),
700                ]);
701            }
702
703            throw $e;
704        }
705    }
706
707    /** @param array<mixed> $data */
708    private function resolveParentUid(object $node, array $data): ?string
709    {
710        $parentUid = array_key_exists('parent', $data)
711            ? $data['parent']
712            : Factory::meta($node, 'parent');
713
714        if ($parentUid === null) {
715            return null;
716        }
717
718        if (!is_string($parentUid)) {
719            throw new HttpBadRequest(payload: [
720                'message' => __('node:parent-not-string'),
721            ]);
722        }
723
724        $parentUid = trim($parentUid);
725
726        if ($parentUid === '') {
727            return null;
728        }
729
730        return $parentUid;
731    }
732
733    private function resolveParentId(?string $parentUid): ?int
734    {
735        if ($parentUid === null) {
736            return null;
737        }
738
739        $parent = $this->db
740            ->nodes
741            ->parentIdByUid(['uid' => $parentUid])
742            ->first();
743
744        if (!$parent) {
745            throw new HttpBadRequest(payload: [
746                'message' => __('node:invalid-parent', ['uid' => $parentUid]),
747            ]);
748        }
749
750        return (int) $parent['node'];
751    }
752
753    /** @param array<mixed> $data */
754    private function assertUidUnchanged(object $node, array $data): void
755    {
756        $uid = Factory::meta($node, 'uid');
757
758        if (!is_string($uid) || $data['uid'] === $uid) {
759            return;
760        }
761
762        throw new HttpBadRequest(payload: [
763            'message' => __('node:uid-immutable'),
764        ]);
765    }
766
767    /**
768     * @param array<string, mixed> $data
769     * @return array<string, mixed>
770     */
771    private function normalizeSubmittedHandle(array $data): array
772    {
773        if (!array_key_exists('handle', $data) || !is_string($data['handle'])) {
774            return $data;
775        }
776
777        $handle = trim($data['handle']);
778        $data['handle'] = $handle === '' ? null : $handle;
779
780        return $data;
781    }
782
783    /**
784     * @param array<mixed> $data
785     * @return array<mixed>
786     */
787    private function completeHandle(object $node, array $data): array
788    {
789        if (!array_key_exists('handle', $data)) {
790            $data['handle'] = Factory::meta($node, 'handle');
791        }
792
793        $data['handle'] = $this->resolveHandle($data);
794
795        return $data;
796    }
797
798    /** @param array<mixed> $data */
799    private function resolveHandle(array $data): ?string
800    {
801        $handle = $data['handle'] ?? null;
802
803        if (!is_string($handle)) {
804            return null;
805        }
806
807        $handle = trim($handle);
808
809        return $handle === '' ? null : $handle;
810    }
811
812    private function ensureRouteHandle(object $node, ?string $handle): void
813    {
814        $route = $this->types->get($node::class, 'route');
815
816        if (!$this->routeNeedsHandle($route) || $handle !== null) {
817            return;
818        }
819
820        throw new HttpBadRequest(payload: [
821            'message' => __('node:handle-required'),
822        ]);
823    }
824
825    /**
826     * @param array<mixed> $data
827     * @return array<mixed>
828     */
829    private function completeGeneratedPaths(
830        object $node,
831        array $data,
832        Locales $locales,
833        ?int $parentId,
834    ): array {
835        if (!$this->needsGeneratedPaths($data)) {
836            return $data;
837        }
838
839        try {
840            $data['generatedPaths'] = $this->routePathGenerator->generate(
841                $node::class,
842                $data,
843                $locales,
844                $parentId,
845            );
846        } catch (RoutePathError $e) {
847            throw new HttpBadRequest(
848                payload: [
849                    'message' => $e->getMessage(),
850                ],
851                previous: $e,
852            );
853        }
854
855        return $data;
856    }
857
858    /** @param array<mixed> $data */
859    private function needsGeneratedPaths(array $data): bool
860    {
861        foreach ($data['paths'] ?? [] as $path) {
862            if (is_string($path) ? trim($path) !== '' : (bool) $path) {
863                return false;
864            }
865        }
866
867        return true;
868    }
869
870    private function routeNeedsHandle(mixed $route): bool
871    {
872        if (is_string($route)) {
873            return $this->routeContainsHandle($route);
874        }
875
876        if (!is_array($route)) {
877            return false;
878        }
879
880        foreach ($route as $localizedRoute) {
881            if (is_string($localizedRoute) && $this->routeContainsHandle($localizedRoute)) {
882                return true;
883            }
884        }
885
886        return false;
887    }
888
889    private function routeContainsHandle(string $route): bool
890    {
891        return preg_match('/\{\s*handle\s*(?:\|\s*[^{}|]+\s*)*\}/', $route) === 1;
892    }
893
894    private function ensureTypeExists(string $handle): void
895    {
896        $type = $this->db->nodes->type(['handle' => $handle])->first();
897
898        if (!$type) {
899            $this->db->nodes->addType([
900                'handle' => $handle,
901            ])->run();
902        }
903    }
904}