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.
This commit is contained in:
Cline committed 2026-10-13 19:41:03 -05:00
1 parent 6b9fb54b4f
commit a829d6e8c6
21 files changed
+1903 -61

No files matched your search

+433
View File
@@ -0,0 +1,433 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use DomainException;
use InvalidArgumentException;
use MeshVault\Catalog\CatalogService;
use MeshVault\Catalog\CatalogServiceInterface;
use MeshVault\Repository\PdoCatalogRepository;
use PDO;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class CatalogContractsTest extends TestCase
{
private PDO $pdo;
private PdoCatalogRepository $repository;
private CatalogService $service;
private string $database;
private string $physicalRoot;
private function physicalFiles(?int $owner): array
{
$this->physicalRoot = RepositoryDatabase::directory('contract-files');
$ids = [];
foreach (['parts/one.stl', 'parts/two.stl', 'images/photo.png', 'docs/manual.pdf'] as $path) {
if (!is_dir(dirname($this->physicalRoot . '/' . $path))) {
mkdir(dirname($this->physicalRoot . '/' . $path));
}
$bytes = "fixture\0" . $path;
file_put_contents($this->physicalRoot . '/' . $path, $bytes);
touch($this->physicalRoot . '/' . $path, 1000000000);
$ids[] = $this->service->registerFile($owner, $path, hash('sha256', $bytes), strlen($bytes));
}
return $ids;
}
protected function setUp(): void
{
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-contracts-');
$this->pdo = RepositoryDatabase::migrate($this->database);
$this->repository = new PdoCatalogRepository($this->pdo);
$this->service = new CatalogService($this->repository);
}
protected function tearDown(): void
{
unset($this->service, $this->repository, $this->pdo);
gc_collect_cycles();
RepositoryDatabase::cleanup($this->database);
if (isset($this->physicalRoot)) {
RepositoryDatabase::remove($this->physicalRoot);
}
}
public function testServiceBoundaryAndNormalizedReadOnlyLookups(): void
{
self::assertInstanceOf(CatalogServiceInterface::class, $this->service);
foreach ([$this->repository, $this->service] as $catalog) {
self::assertNull($catalog->file(999));
self::assertNull($catalog->fileByManagedPath('absent.stl'));
}
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn());
$id = $this->service->registerFile(null, 'physical/nested/a.stl', 'hash', 0);
$file = $this->service->file($id);
self::assertSame($file, $this->repository->fileByManagedPath('physical/nested/a.stl'));
self::assertSame($id, $file['id']);
self::assertNull($file['model_id']);
self::assertIsInt($file['folder_id']);
self::assertSame(0, $file['size']);
$this->pdo->exec('UPDATE files SET hash = NULL, size = NULL');
self::assertNull($this->service->file($id)['hash']);
self::assertNull($this->service->file($id)['size']);
}
public function testRegistrationDerivesFoldersAndReplayPreservesEveryColumn(): void
{
$owner = $this->service->createModel('/home', 'Model');
$id = $this->service->registerFile($owner, 'physical/nested/a.stl', 'hash', 1, 'model/stl', 'Missing');
$before = $this->service->file($id);
self::assertSame($owner, $before['model_id']);
self::assertSame('/physical/nested', $this->pdo->query(
'SELECT path FROM folders WHERE id = ' . $before['folder_id']
)->fetchColumn());
self::assertSame($id, $this->service->registerFile(
$owner,
'physical/nested/a.stl',
'hash',
1,
'other',
'Present'
));
self::assertSame($before, $this->service->file($id));
self::assertNotSame($id, $this->service->registerFile(null, 'b.stl', 'hash', 1));
$this->service->detachFiles($owner, [$id]);
$this->expectException(DomainException::class);
$this->expectExceptionMessage('Managed path already records a different file.');
$this->service->registerFile($owner, 'physical/nested/a.stl', 'hash', 1);
}
#[DataProvider('invalidRegistration')]
public function testDirectRegistrationValidation(array $arguments): void
{
try {
$this->repository->recordFile(...$arguments);
self::fail('Malformed registration must fail');
} catch (InvalidArgumentException) {
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn());
}
}
public function testMissingOwnerIsDomainFailureWithoutFolderWrites(): void
{
try {
$this->repository->recordFile(999, 'new/nested/a.stl', 'hash', 0);
self::fail('Missing owner must fail');
} catch (DomainException $error) {
self::assertSame('Unknown model.', $error->getMessage());
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn());
}
}
public function testNullStoredSizeDoesNotReplayAsZero(): void
{
$this->repository->recordFile(null, 'a.stl', 'hash', 0);
$this->pdo->exec('UPDATE files SET size = NULL');
$this->expectException(DomainException::class);
$this->repository->recordFile(null, 'a.stl', 'hash', 0);
}
public function testAtomicReturnRollbackAndFailurePreservesCallerWrites(): void
{
self::assertSame('result', $this->service->atomic(static fn (): string => 'result'));
$this->pdo->beginTransaction();
$owner = $this->service->createModel('/home', 'Earlier');
$error = new RuntimeException('later failure');
try {
$this->service->atomic(function () use ($owner, $error): void {
$this->service->registerFile($owner, 'new/nested/a.stl', 'hash', 1);
throw $error;
});
self::fail('Failure must escape');
} catch (RuntimeException $caught) {
self::assertSame($error, $caught);
}
self::assertTrue($this->pdo->inTransaction());
self::assertNotNull($this->service->aggregate($owner));
self::assertNull($this->service->fileByManagedPath('new/nested/a.stl'));
self::assertSame(2, (int) $this->pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn());
$id = $this->service->registerFile(null, 'a.stl', 'hash', 1);
self::assertTrue($this->pdo->inTransaction());
$this->pdo->rollBack();
self::assertNull($this->service->file($id));
self::assertNull($this->service->aggregate($owner));
}
public function testLookupValidationIsEnforcedByRepositoryAndService(): void
{
foreach ([$this->repository, $this->service] as $catalog) {
foreach (
[['file', 0], ['file', -1], ['fileByManagedPath', '../a'],
['fileByManagedPath', "\xff.stl"], ['fileByManagedPath', 'a//b']] as [$method, $value]
) {
try {
$catalog->$method($value);
self::fail('Invalid lookup must fail');
} catch (InvalidArgumentException) {
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM folders')->fetchColumn());
}
}
}
}
public function testMetadataAndStatusBoundaryExists(): void
{
foreach ([$this->repository, $this->service] as $catalog) {
foreach (['modelMetadata', 'updateModelMetadata', 'changeFileStatus'] as $method) {
self::assertTrue(method_exists($catalog, $method), $method);
}
self::assertFalse(method_exists($catalog, 'setFileStatus'));
}
}
public function testMetadataRawNamedTupleNoopLegacyAndAba(): void
{
$id = $this->service->createModel('/home', 'T');
$file = $this->service->registerFile($id, 'a.stl', 'hash', 1);
$this->pdo->exec("UPDATE models SET notes = '', source_url = 'bad URL', updated_at = '2000-01-01'");
$before = $this->service->aggregate($id);
$raw = ['title' => 'T', 'notes' => '', 'sourceURL' => 'bad URL'];
self::assertSame($raw, $this->repository->modelMetadata($id));
self::assertSame($raw, $this->service->updateModelMetadata($id, array_reverse($raw, true), $raw));
self::assertSame($before, $this->service->aggregate($id));
$edited = array_replace($raw, ['title' => 'New']);
self::assertSame($edited, $this->repository->updateModelMetadata($id, $raw, $edited));
$after = $this->service->aggregate($id);
foreach (['id', 'folder_id', 'added_at', 'notes', 'source_url'] as $key) {
self::assertSame($before['model'][$key], $after['model'][$key]);
}
self::assertSame($before['files'], $after['files']);
self::assertNotSame($before['model']['updated_at'], $after['model']['updated_at']);
$this->service->updateModelMetadata($id, $edited, $raw);
self::assertSame($edited, $this->service->updateModelMetadata($id, $raw, $edited));
self::assertSame($file, $after['files'][0]['id']);
$cleared = array_replace($edited, ['sourceURL' => null]);
self::assertSame($cleared, $this->service->updateModelMetadata($id, $edited, $cleared));
}
public function testMetadataStaleBeforeValidationOrNoopAndIndependentFieldsConflict(): void
{
$id = $this->service->createModel('/', 'T');
$raw = $this->service->modelMetadata($id);
$current = array_replace($raw, ['notes' => 'changed']);
$this->service->updateModelMetadata($id, $raw, $current);
foreach ([$current, array_replace($raw, ['title' => "bad\0"]), $raw] as $replacement) {
try {
$this->repository->updateModelMetadata($id, $raw, $replacement);
self::fail('Stale tuple must fail');
} catch (DomainException $error) {
self::assertSame('Model metadata has changed.', $error->getMessage());
}
}
self::assertSame($current, $this->service->modelMetadata($id));
}
public function testEveryStatusPairPreservesOtherColumnsAndNoopTimestamp(): void
{
$id = $this->service->registerFile(null, 'a.stl', 'hash', 5, 'model/stl');
foreach (['Present', 'Missing', 'Updated', 'Duplicate'] as $from) {
foreach (['Present', 'Missing', 'Updated', 'Duplicate'] as $to) {
$this->pdo->exec("UPDATE files SET status = '$from', updated_at = '2000-01-01'");
$before = $this->service->file($id);
$after = $this->service->changeFileStatus($id, $from, $to);
self::assertSame($to, $after['status']);
self::assertSame($after, $this->repository->file($id));
if ($from === $to) {
self::assertSame($before, $after);
} else {
self::assertNotSame($before['updated_at'], $after['updated_at']);
}
unset($before['status'], $before['updated_at'], $after['status'], $after['updated_at']);
self::assertSame($before, $after);
}
}
$this->expectException(DomainException::class);
$this->expectExceptionMessage('File status has changed.');
$this->repository->changeFileStatus($id, 'Present', 'Duplicate');
}
public function testCreationRejectsNulThroughBothPaths(): void
{
foreach (['createModel', 'ensureModel'] as $method) {
try {
$this->repository->$method('/', "bad\0title");
self::fail('NUL must fail');
} catch (InvalidArgumentException) {
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM models')->fetchColumn());
}
}
}
public function testMutationValidationAndMissingIdentities(): void
{
$tuple = ['title' => 'T', 'notes' => null, 'sourceURL' => null];
foreach ([$this->repository, $this->service] as $catalog) {
self::assertNull($catalog->modelMetadata(999));
$invalid = [
['modelMetadata', [0]], ['modelMetadata', [-1]],
['updateModelMetadata', [0, $tuple, $tuple]],
['updateModelMetadata', [999, [], $tuple]],
['updateModelMetadata', [999, $tuple, []]],
['changeFileStatus', [0, 'Present', 'Present']],
['changeFileStatus', [999, 'invalid', 'Present']],
['changeFileStatus', [999, 'Present', 'invalid']],
];
foreach ($invalid as [$method, $args]) {
try {
$catalog->$method(...$args);
self::fail('Invalid input must fail');
} catch (InvalidArgumentException) {
self::assertNull($catalog->modelMetadata(999));
}
}
foreach (
[['updateModelMetadata', [999, $tuple, $tuple], 'Unknown model.'],
['changeFileStatus', [999, 'Present', 'Present'], 'Unknown file.']] as [$method, $args, $message]
) {
try {
$catalog->$method(...$args);
self::fail('Missing identity must fail');
} catch (DomainException $error) {
self::assertSame($message, $error->getMessage());
}
}
}
}
public function testMetadataEffectiveNoopAndRawNullDistinction(): void
{
$id = $this->service->createModel('/', 'T');
$this->pdo->exec("UPDATE models SET updated_at = '2000-01-01'");
$raw = $this->service->modelMetadata($id);
$before = $this->service->aggregate($id);
self::assertSame($raw, $this->service->updateModelMetadata(
$id,
$raw,
array_replace($raw, ['notes' => '', 'sourceURL' => ''])
));
self::assertSame($before, $this->service->aggregate($id));
$this->pdo->exec("UPDATE models SET source_url = ''");
try {
$this->service->updateModelMetadata($id, $raw, $raw);
self::fail('Raw null must not match empty string');
} catch (DomainException $error) {
self::assertSame('Model metadata has changed.', $error->getMessage());
}
$raw = $this->service->modelMetadata($id);
self::assertSame('', $this->service->updateModelMetadata($id, $raw, $raw)['sourceURL']);
self::assertNull($this->service->updateModelMetadata(
$id,
$raw,
array_replace($raw, ['sourceURL' => null])
)['sourceURL']);
}
public function testPhysicalSnapshotsSurviveMutationsNoopsAndRejectedChanges(): void
{
$owner = $this->service->createModel('/', 'Multipart');
$other = $this->service->createModel('/other', 'Other');
$ids = $this->physicalFiles($owner);
$snapshot = PhysicalFileSnapshot::capture($this->physicalRoot);
$raw = $this->service->modelMetadata($owner);
$edited = array_replace($raw, ['title' => 'Edited', 'notes' => 'Notes']);
$operations = [
fn () => $this->service->updateModelMetadata($owner, $raw, $edited),
fn () => $this->service->updateModelMetadata($owner, $edited, $edited),
fn () => $this->service->changeFileStatus($ids[0], 'Present', 'Missing'),
fn () => $this->service->changeFileStatus($ids[0], 'Missing', 'Missing'),
fn () => $this->service->reassignFiles($owner, $other, $ids),
fn () => $this->repository->changeFileOwner($ids, $other, $other),
fn () => $this->service->detachFiles($other, $ids),
fn () => $this->repository->changeFileOwner($ids, null, null),
fn () => $this->service->assignFiles($owner, $ids),
];
foreach ($operations as $index => $operation) {
$operation();
self::assertSame($snapshot, PhysicalFileSnapshot::capture($this->physicalRoot), "Mutation $index");
}
$before = $this->service->aggregate($owner);
$rejections = [
fn () => $this->service->updateModelMetadata($owner, $raw, $edited),
fn () => $this->service->updateModelMetadata($owner, $edited, array_replace($edited, ['title' => ''])),
fn () => $this->service->changeFileStatus($ids[0], 'Present', 'Missing'),
fn () => $this->service->changeFileStatus($ids[0], 'Missing', 'invalid'),
fn () => $this->service->reassignFiles($other, $owner, $ids),
];
foreach ($rejections as $index => $operation) {
try {
$operation();
self::fail('Expected rejected mutation');
} catch (DomainException | InvalidArgumentException) {
self::assertSame($before, $this->service->aggregate($owner));
self::assertSame($snapshot, PhysicalFileSnapshot::capture($this->physicalRoot), "Rejection $index");
}
}
}
public function testMutationsRollbackWithCallerAndLaterSqlFailure(): void
{
$id = $this->service->createModel('/', 'T');
$files = $this->physicalFiles($id);
$file = $files[0];
$snapshot = PhysicalFileSnapshot::capture($this->physicalRoot);
$raw = $this->service->modelMetadata($id);
$new = array_replace($raw, ['title' => 'New']);
$before = $this->service->file($file);
$this->pdo->beginTransaction();
$earlier = $this->service->createModel('/', 'Earlier');
$postgres = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME) === 'pgsql';
if ($postgres) {
$this->pdo->exec('ALTER TABLE files ADD CONSTRAINT "forced later write" CHECK (status <> \'Missing\')');
} else {
$this->pdo->exec("CREATE TRIGGER fail_status BEFORE UPDATE OF status ON files
BEGIN SELECT RAISE(ABORT, 'forced later write'); END");
}
try {
$this->service->atomic(function () use ($id, $file, $files, $raw, $new): void {
$this->service->detachFiles($id, $files);
$this->service->updateModelMetadata($id, $raw, $new);
$this->repository->changeFileStatus($file, 'Present', 'Missing');
});
self::fail('SQL failure must escape');
} catch (\PDOException $error) {
self::assertStringContainsString('forced later write', $error->getMessage());
}
self::assertTrue($this->pdo->inTransaction());
self::assertNotNull($this->service->aggregate($earlier));
self::assertSame($raw, $this->service->modelMetadata($id));
self::assertSame($before, $this->service->file($file));
self::assertSame($files, array_column($this->service->aggregate($id)['files'], 'id'));
self::assertSame($snapshot, PhysicalFileSnapshot::capture($this->physicalRoot));
$this->pdo->exec($postgres
? 'ALTER TABLE files DROP CONSTRAINT "forced later write"'
: 'DROP TRIGGER fail_status');
$this->service->updateModelMetadata($id, $raw, $new);
$this->service->changeFileStatus($file, 'Present', 'Missing');
$this->service->detachFiles($id, $files);
self::assertSame($snapshot, PhysicalFileSnapshot::capture($this->physicalRoot));
self::assertTrue($this->pdo->inTransaction());
$this->pdo->rollBack();
self::assertSame($raw, $this->service->modelMetadata($id));
self::assertSame($before, $this->service->file($file));
self::assertNull($this->service->aggregate($earlier));
self::assertSame($files, array_column($this->service->aggregate($id)['files'], 'id'));
self::assertSame($snapshot, PhysicalFileSnapshot::capture($this->physicalRoot));
}
public static function invalidRegistration(): array
{
return array_map(static fn (array $args): array => [$args], [
[null, 'a.stl', '', 0], [null, 'a.stl', str_repeat('a', 65), 0],
[null, 'a.stl', "bad\0hash", 0], [null, 'a.stl', "\xff", 0],
[null, 'a.stl', 'hash', -1], [0, 'a.stl', 'hash', 0],
[null, 'a.stl', 'hash', 0, str_repeat('a', 129)],
[null, 'a.stl', 'hash', 0, "bad\0mime"], [null, 'a.stl', 'hash', 0, "\xff"],
[null, "\xff.stl", 'hash', 0], [null, '../a.stl', 'hash', 0],
[null, 'a.stl', 'hash', 0, null, 'invalid'],
]);
}
}