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:
1 parent
6b9fb54b4f
commit
a829d6e8c6
21 files changed
+1903
-61
No files matched your search
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace MeshVault\Catalog;
|
||||
|
||||
use InvalidArgumentException;
|
||||
|
||||
final class ModelMetadata
|
||||
{
|
||||
public static function validateTitle(string $title): void
|
||||
{
|
||||
$length = preg_match_all('/./us', $title);
|
||||
if (
|
||||
$length === false || $length < 1 || $length > 255 || preg_match('/^\s*$/u', $title)
|
||||
|| str_contains($title, "\0")
|
||||
) {
|
||||
throw new InvalidArgumentException('Model name must contain 1 to 255 characters and not be blank or NUL.');
|
||||
}
|
||||
}
|
||||
|
||||
public static function validateShape(array $metadata): void
|
||||
{
|
||||
if (count($metadata) !== 3 || !isset($metadata['title']) || !is_string($metadata['title'])) {
|
||||
throw new InvalidArgumentException('Metadata requires exactly title, notes and sourceURL.');
|
||||
}
|
||||
foreach (['notes', 'sourceURL'] as $field) {
|
||||
if (
|
||||
!array_key_exists($field, $metadata)
|
||||
|| ($metadata[$field] !== null && !is_string($metadata[$field]))
|
||||
) {
|
||||
throw new InvalidArgumentException('Metadata requires nullable string notes and sourceURL.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate/normalize changed fields only; expected is always a raw snapshot. */
|
||||
public static function replacement(array $expected, array $replacement): array
|
||||
{
|
||||
self::validateShape($expected);
|
||||
self::validateShape($replacement);
|
||||
$result = [];
|
||||
foreach (['title', 'notes', 'sourceURL'] as $field) {
|
||||
$value = $replacement[$field];
|
||||
if ($value !== $expected[$field]) {
|
||||
if ($field === 'title') {
|
||||
self::validateTitle($value);
|
||||
} elseif ($value === '' || $value === null) {
|
||||
$value = null;
|
||||
} elseif ($field === 'notes') {
|
||||
if (preg_match('//u', $value) !== 1 || str_contains($value, "\0")) {
|
||||
throw new InvalidArgumentException('Notes must be valid UTF-8 without NUL.');
|
||||
}
|
||||
} else {
|
||||
self::validateUrl($value);
|
||||
}
|
||||
}
|
||||
$result[$field] = $value;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
private static function validateUrl(string $url): void
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
if (
|
||||
strlen($url) > 2048 || preg_match('/[^\x21-\x7e]|\\\\|%(?![0-9a-fA-F]{2})/', $url)
|
||||
|| $parts === false || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true)
|
||||
|| empty($parts['host']) || isset($parts['user']) || isset($parts['pass'])
|
||||
|| filter_var($url, FILTER_VALIDATE_URL) === false
|
||||
) {
|
||||
throw new InvalidArgumentException('Source URL must be an absolute ASCII HTTP(S) URL without userinfo.');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user