feat: establish catalog identity and atomic file ownership

This commit is contained in:
Cline committed 2026-10-12 19:38:38 -05:00
1 parent 390a7a557a
commit 6b9fb54b4f
16 files changed
+1381 -23

No files matched your search

+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace MeshVault\Catalog;
use MeshVault\Repository\CatalogRepositoryInterface;
final class CatalogService
{
public function __construct(private readonly CatalogRepositoryInterface $catalog)
{
}
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));
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace MeshVault\Catalog;
use InvalidArgumentException;
/** Lexical identity only; filesystem authorization remains PathSafety's responsibility. */
final class ManagedPath
{
public static function validate(string $path): string
{
if (
$path === '' || str_starts_with($path, '/') || str_contains($path, '\\')
|| str_contains($path, "\0") || preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*:/', $path)
) {
throw new InvalidArgumentException('Managed path must be a canonical relative path.');
}
foreach (explode('/', $path) as $segment) {
if (
$segment === '' || $segment === '.' || $segment === '..'
|| (PHP_OS_FAMILY === 'Windows'
&& (str_contains($segment, ':') || rtrim($segment, '. ') !== $segment))
) {
throw new InvalidArgumentException('Managed path contains a noncanonical segment.');
}
}
return $path;
}
}
+16 -1
View File
@@ -6,10 +6,17 @@ namespace MeshVault\Repository;
interface CatalogRepositoryInterface
{
/** Run an operation atomically, using a savepoint inside a caller transaction. */
public function atomic(callable $operation): mixed;
/** Always create a new identity, including for an existing folder/title pair. */
public function createModel(string $folderPath, string $modelName): int;
/** Legacy lookup: create on zero matches, reuse one, reject ambiguity. */
public function ensureModel(string $folderPath, string $modelName): int;
public function recordFile(
int $modelId,
?int $modelId,
string $managedPath,
string $hash,
int $size,
@@ -20,5 +27,13 @@ interface CatalogRepositoryInterface
/** @return array{model: array, folder: array, files: array}|null */
public function aggregate(int $modelId): ?array;
/**
* Atomically change a nonempty list of distinct positive file IDs.
* Null expected ownership means unassigned, never a wildcard.
* Invalid arguments throw InvalidArgumentException; missing/stale identities throw DomainException.
* SQL and lock failures propagate unchanged, without retry.
*/
public function changeFileOwner(array $fileIds, ?int $expectedOwnerId, ?int $targetOwnerId): void;
public function setFileStatus(int $fileId, string $status): void;
}
+97 -13
View File
@@ -6,30 +6,65 @@ namespace MeshVault\Repository;
use DomainException;
use InvalidArgumentException;
use MeshVault\Catalog\ManagedPath;
use PDO;
final class PdoCatalogRepository extends PdoRepository implements CatalogRepositoryInterface
{
public function atomic(callable $operation): mixed
{
return $this->transaction($operation);
}
public function createModel(string $folderPath, string $modelName): int
{
return $this->createOrFindModel($folderPath, $modelName, false);
}
public function ensureModel(string $folderPath, string $modelName): int
{
if ($modelName === '') {
throw new InvalidArgumentException('Model name must not be empty.');
return $this->createOrFindModel($folderPath, $modelName, true);
}
private function createOrFindModel(string $folderPath, string $modelName, bool $legacy): int
{
$length = preg_match_all('/./us', $modelName);
if ($length === false || $length > 255 || preg_match('/^\s*$/u', $modelName)) {
throw new InvalidArgumentException('Model name must contain 1 to 255 characters and not be blank.');
}
return $this->transaction(function () use ($folderPath, $modelName): int {
if ($folderPath === '') {
throw new InvalidArgumentException('A home folder is required.');
}
return $this->transaction(function () use ($folderPath, $modelName, $legacy): int {
$this->lockRoot();
$folder = $this->ensureFolder($folderPath);
$this->execute(
'INSERT INTO models (folder_id, name) VALUES (?, ?) ON CONFLICT (folder_id, name) DO NOTHING',
[$folder, $modelName]
);
if ($legacy) {
$matches = $this->execute(
'SELECT id FROM models WHERE folder_id = ? AND name = ? ORDER BY id LIMIT 2',
[$folder, $modelName]
)->fetchAll(PDO::FETCH_COLUMN);
if (count($matches) > 1) {
throw new DomainException('Ambiguous model title in home folder.');
}
if ($matches !== []) {
return (int) $matches[0];
}
}
return (int) $this->row(
'SELECT id FROM models WHERE folder_id = ? AND name = ?',
'INSERT INTO models (folder_id, name) VALUES (?, ?) RETURNING id',
[$folder, $modelName]
)['id'];
});
}
private function lockRoot(): void
{
$this->ensureFolder('/');
$this->execute("UPDATE folders SET name = name WHERE path = '/'");
}
public function recordFile(
int $modelId,
?int $modelId,
string $managedPath,
string $hash,
int $size,
@@ -37,17 +72,21 @@ final class PdoCatalogRepository extends PdoRepository implements CatalogReposit
string $status = 'Present'
): int {
$this->validateStatus($status);
if ($managedPath === '' || $hash === '' || $size < 0) {
ManagedPath::validate($managedPath);
if ($modelId !== null && $modelId <= 0) {
throw new InvalidArgumentException('Model ID must be positive or null.');
}
if ($hash === '' || $size < 0) {
throw new InvalidArgumentException('A file requires a path, hash and non-negative size.');
}
return $this->transaction(function () use ($modelId, $managedPath, $hash, $size, $mime, $status): int {
// Serialize catalog writers on the root row, including paths assigned to different models.
$this->ensureFolder('/');
$this->execute("UPDATE folders SET name = name WHERE path = '/'");
$this->lockRoot();
$existing = $this->row('SELECT * FROM files WHERE managed_path = ?', [$managedPath]);
if ($existing !== null) {
$owner = $existing['model_id'] === null ? null : (int) $existing['model_id'];
if (
(int) $existing['model_id'] !== $modelId || $existing['hash'] !== $hash
$owner !== $modelId || $existing['hash'] !== $hash
|| (int) $existing['size'] !== $size
) {
throw new DomainException('Managed path already records a different file.');
@@ -80,6 +119,51 @@ final class PdoCatalogRepository extends PdoRepository implements CatalogReposit
});
}
public function changeFileOwner(array $fileIds, ?int $expectedOwnerId, ?int $targetOwnerId): void
{
if ($fileIds === [] || !array_is_list($fileIds)) {
throw new InvalidArgumentException('A nonempty list of file IDs is required.');
}
$seen = [];
foreach ($fileIds as $id) {
if (!is_int($id) || $id <= 0 || isset($seen[$id])) {
throw new InvalidArgumentException('File IDs must be distinct positive integers.');
}
$seen[$id] = true;
}
foreach ([$expectedOwnerId, $targetOwnerId] as $owner) {
if ($owner !== null && $owner <= 0) {
throw new InvalidArgumentException('Model IDs must be positive or null.');
}
}
sort($fileIds, SORT_NUMERIC);
$this->transaction(function () use ($fileIds, $expectedOwnerId, $targetOwnerId): void {
foreach ([$expectedOwnerId, $targetOwnerId] as $owner) {
if ($owner !== null && $this->row('SELECT id FROM models WHERE id = ?', [$owner]) === null) {
throw new DomainException('Unknown model.');
}
}
$lock = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? ' FOR UPDATE' : '';
foreach ($fileIds as $id) {
$file = $this->row('SELECT model_id FROM files WHERE id = ?' . $lock, [$id]);
if ($file === null) {
throw new DomainException('Unknown file.');
}
$owner = $file['model_id'] === null ? null : (int) $file['model_id'];
if ($owner !== $expectedOwnerId) {
throw new DomainException('File ownership has changed.');
}
}
// Even a no-op must lock and validate the complete selection first.
if ($expectedOwnerId === $targetOwnerId) {
return;
}
foreach ($fileIds as $id) {
$this->execute('UPDATE files SET model_id = ? WHERE id = ?', [$targetOwnerId, $id]);
}
});
}
public function setFileStatus(int $fileId, string $status): void
{
$this->validateStatus($status);