$payload * @return array> */ private function items(array $payload, string $path = ''): array { $found = []; foreach ($payload as $key => $value) { $pointer = ltrim($path.'.'.$key, '.'); if (! is_array($value)) { continue; } if (! array_is_list($value)) { $found[$pointer] = $value; } $found += $this->items($value, $pointer); } return $found; } /** @param array $before * @param array $after * @param array $metadata * @return array */ public function mutate(array $before, array $after, array $metadata, User $actor, string $reason): array { $old = $this->items($before); $new = $this->items($after); $active = $metadata['active'] ?? []; $retired = $metadata['deleted'] ?? []; $next = []; $used = []; foreach ($new as $path => $value) { $fingerprint = OpportunityCanonicalValue::hash($value); $match = null; // A collection reorder preserves identity by bytes, not by its new index. foreach ($active as $previousPath => $item) { if (! isset($used[$previousPath]) && ($item['fingerprint'] ?? '') === $fingerprint && preg_replace('/\.\d+(?=\.|$)/', '.*', $previousPath) === preg_replace('/\.\d+(?=\.|$)/', '.*', $path)) { $match = $previousPath; break; } } // Named singleton modules have a stable path; list rows need an unambiguous match. if ($match === null && ! preg_match('/\.\d+(?:\.|$)/', $path) && isset($active[$path]) && ! isset($used[$path])) { $match = $path; } $item = $match !== null ? $active[$match] : null; if ($match !== null) { $used[$match] = true; } $changed = $item === null || ($item['fingerprint'] ?? '') !== $fingerprint; $legacy = $item === null && isset($old[$path]); $item ??= ['id' => (string) Str::uuid(), 'version' => 0, 'created_by' => $legacy ? null : $actor->id, 'created_at' => $legacy ? null : now()->toIso8601String(), 'first_observed_at' => now()->toIso8601String(), 'identity_source' => $legacy ? 'legacy_first_observation_not_creation' : 'server_generated_new_item']; if ($changed) { $item['version']++; $item['updated_by'] = $actor->id; $item['updated_at'] = now()->toIso8601String(); } $next[$path] = [...$item, 'fingerprint' => $fingerprint, 'is_deleted' => false, 'active' => true, 'deleted_at' => null, 'deleted_by' => null, 'delete_reason' => null]; } foreach ($active as $path => $item) { if (isset($used[$path])) { continue; } $retired[$item['id']] = [...$item, 'path' => $path, 'version' => $item['version'] + 1, 'is_deleted' => true, 'active' => false, 'deleted_at' => now()->toIso8601String(), 'deleted_by' => $actor->id, 'delete_reason' => $reason, 'last_value' => $old[$path] ?? null]; } return ['active' => $next, 'deleted' => $retired, 'rule' => 'existing-json-identities-v1']; } /** @param array $payload * @param array $metadata * @return array */ public function project(array $payload, array $metadata): array { // Deepest paths first so a parent does not overwrite the children's identities. $paths = array_keys($metadata['active'] ?? []); foreach ($paths as $path) { if (! is_string($path)) { throw new \UnexpectedValueException('Canonical item paths must be named fields.'); } } usort($paths, fn ($a, $b) => substr_count($b, '.') <=> substr_count($a, '.')); foreach ($paths as $path) { $value = Arr::get($payload, $path); if (! is_array($value)) { continue; } $item = $metadata['active'][$path]; unset($item['fingerprint']); Arr::set($payload, $path, [...$value, ...$item]); } return $payload; } }