Files
MeshVault/src/Catalog/CatalogService.php
T
Cline a829d6e8c6 feat: catalog shared contracts (Tasks 1-4)
Task 1: Establish catalog identity and atomic file ownership
- CatalogService, CatalogServiceInterface, ModelMetadata DTO
- PdoCatalogRepository with atomic operations, savepoint support
- FOR UPDATE file locks in ascending order, FOR NO KEY UPDATE for metadata
- FK-compatible lock composition documented (spec §6)

Task 2: M3 import & indexing engine delivery layer
- ImportService, ImportWorker with durable collision handling
- SpaceChecker, StreamingHasher, VerifiedCopier, StlValidator
- FileEnumerator, LocalFilesystem, PathSafety
- Console command wiring via ContainerWiringTest

Task 3: Importer integration and repair
- LegacyGuessedImportGroupingAdapter isolates first-directory guessing
- ImportService uses adapter; ImportWorker uses CatalogServiceInterface
- Null owners remain null; DomainException mapped to CATALOG_CONFLICT
- SQL failure after registration/path update with caller sentinel rollback

Task 4: PostgreSQL contention evidence and bounded acceptance
- 6 new PostgresCatalogContractsTest cases with independent sessions
- Generated schemas, 250ms lock timeouts, 5s statement timeouts
- Ascending file locks, FOR SHARE contention, stale-owner rejection
- Rollback/retry, nested transaction survival, root creation contention
- Negative controls: replay FOR UPDATE removal (11 failures), metadata FOR UPDATE (4 FK KEY SHARE timeouts)
- Fresh Docker PostgreSQL 17 / PHP 8.4.26: 473 tests, 2718 assertions, 10 skips, exit 0

All local: 474 tests, 2347 assertions, 54 skips, foundation/lint clean.
2026-10-13 19:41:03 -05:00

83 lines
2.5 KiB
PHP

<?php
declare(strict_types=1);
namespace MeshVault\Catalog;
use MeshVault\Repository\CatalogRepositoryInterface;
final class CatalogService implements CatalogServiceInterface
{
public function __construct(private readonly CatalogRepositoryInterface $catalog)
{
}
/** @see CatalogServiceInterface::atomic() for supported lock-order compositions. */
public function atomic(callable $operation): mixed
{
return $this->catalog->atomic($operation);
}
public function modelMetadata(int $modelId): ?array
{
return $this->catalog->modelMetadata($modelId);
}
public function updateModelMetadata(int $modelId, array $expected, array $replacement): array
{
return $this->catalog->updateModelMetadata($modelId, $expected, $replacement);
}
public function changeFileStatus(int $fileId, string $expectedStatus, string $targetStatus): array
{
return $this->catalog->changeFileStatus($fileId, $expectedStatus, $targetStatus);
}
public function file(int $fileId): ?array
{
return $this->catalog->file($fileId);
}
public function fileByManagedPath(string $managedPath): ?array
{
return $this->catalog->fileByManagedPath($managedPath);
}
public function registerFile(
?int $modelId,
string $managedPath,
string $hash,
int $size,
?string $mime = null,
string $status = 'Present'
): int {
return $this->catalog->recordFile($modelId, $managedPath, $hash, $size, $mime, $status);
}
public function createModel(string $folderPath, string $modelName): int
{
return $this->catalog->atomic(fn (): int => $this->catalog->createModel($folderPath, $modelName));
}
/** @return array{model: array, folder: array, files: array}|null */
public function aggregate(int $modelId): ?array
{
return $this->catalog->aggregate($modelId);
}
public function assignFiles(int $modelId, array $fileIds): void
{
$this->catalog->atomic(fn () => $this->catalog->changeFileOwner($fileIds, null, $modelId));
}
public function reassignFiles(int $expectedModelId, int $targetModelId, array $fileIds): void
{
$this->catalog->atomic(fn () => $this->catalog->changeFileOwner($fileIds, $expectedModelId, $targetModelId));
}
public function detachFiles(int $expectedModelId, array $fileIds): void
{
$this->catalog->atomic(fn () => $this->catalog->changeFileOwner($fileIds, $expectedModelId, null));
}
}