feat: establish catalog identity and atomic file ownership
This commit is contained in:
1 parent
390a7a557a
commit
6b9fb54b4f
16 files changed
+1381
-23
No files matched your search
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user