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

+3
View File
@@ -2,6 +2,7 @@
declare(strict_types=1);
use MeshVault\Catalog\CatalogService;
use MeshVault\Config\AppConfig;
use MeshVault\Console\ConsoleServices;
use MeshVault\Console\ContainerConsoleServices;
@@ -48,6 +49,8 @@ return static fn (AppConfig $config): array => [
// Repositories share one PDO connection so the job repository's atomic() is a real transaction.
CatalogRepositoryInterface::class => static fn (ContainerInterface $c): CatalogRepositoryInterface
=> new PdoCatalogRepository($c->get(PDO::class)),
CatalogService::class => static fn (ContainerInterface $c): CatalogService
=> new CatalogService($c->get(CatalogRepositoryInterface::class)),
ImportJobRepositoryInterface::class => static fn (ContainerInterface $c): ImportJobRepositoryInterface
=> new PdoImportJobRepository($c->get(PDO::class)),
ImportService::class => static fn (ContainerInterface $c): ImportService => new ImportService(
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
use MeshVault\Catalog\ManagedPath;
use Phinx\Migration\AbstractMigration;
/** Writers must be stopped for both directions, including the read-only preflight. */
final class CatalogIdentity extends AbstractMigration
{
public function up(): void
{
$problems = [];
foreach ($this->fetchAll('SELECT id, managed_path FROM files ORDER BY id') as $file) {
try {
ManagedPath::validate($file['managed_path']);
} catch (InvalidArgumentException) {
$problems[] = 'noncanonical managed path on file ID ' . $file['id'];
}
}
$duplicates = $this->fetchAll('SELECT f.id FROM files f INNER JOIN
(SELECT managed_path FROM files GROUP BY managed_path HAVING COUNT(*) > 1) d
ON f.managed_path = d.managed_path ORDER BY f.id');
if ($duplicates !== []) {
$problems[] = 'duplicate managed paths on file IDs ' . implode(', ', array_column($duplicates, 'id'));
}
$this->reject(
$problems,
'Resolve paths and ownership explicitly; do not merge or delete records automatically.'
);
$this->table('files')
->dropForeignKey('model_id')
->changeColumn('model_id', 'integer', ['null' => true, 'signed' => false])
->addForeignKey('model_id', 'models', 'id', ['delete' => 'RESTRICT', 'update' => 'NO_ACTION'])
->removeIndexByName('idx_files_model_path')
->addIndex(['managed_path'], ['unique' => true, 'name' => 'idx_files_managed_path'])
->addIndex(['model_id'], ['name' => 'idx_files_model_id'])
->update();
$this->table('models')
->removeIndexByName('idx_models_folder_name')
->addIndex(['folder_id', 'name'], ['name' => 'idx_models_folder_name'])
->update();
}
public function down(): void
{
$problems = [];
$unassigned = $this->fetchAll('SELECT id FROM files WHERE model_id IS NULL ORDER BY id');
if ($unassigned !== []) {
$problems[] = 'unassigned file IDs ' . implode(', ', array_column($unassigned, 'id'));
}
$duplicates = $this->fetchAll('SELECT m.id FROM models m INNER JOIN
(SELECT folder_id, name FROM models GROUP BY folder_id, name HAVING COUNT(*) > 1) d
ON m.folder_id = d.folder_id AND m.name = d.name ORDER BY m.id');
if ($duplicates !== []) {
$problems[] = 'duplicate same-folder titles on model IDs ' . implode(', ', array_column($duplicates, 'id'));
}
$this->reject(
$problems,
'Resolve by explicitly assigning files and disambiguating model titles before retrying.'
);
$this->table('files')
->dropForeignKey('model_id')
->changeColumn('model_id', 'integer', ['null' => false, 'signed' => false])
->addForeignKey('model_id', 'models', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
->removeIndexByName('idx_files_managed_path')
->removeIndexByName('idx_files_model_id')
->addIndex(['model_id', 'managed_path'], ['unique' => true, 'name' => 'idx_files_model_path'])
->update();
$this->table('models')
->removeIndexByName('idx_models_folder_name')
->addIndex(['folder_id', 'name'], ['unique' => true, 'name' => 'idx_models_folder_name'])
->update();
}
private function reject(array $problems, string $guidance): void
{
if ($problems !== []) {
throw new RuntimeException('Catalog identity preflight: ' . implode('; ', $problems) . '. ' . $guidance);
}
}
}
+57
View File
@@ -5,3 +5,60 @@ changes versioned and repeatable; do not introduce a competing runner here.
Subsequent issues #13-18 add physical folders, logical models, distinct files,
metadata and import jobs. Application added_at timestamps are separate from file
mtime. Migrations must never implicitly delete underlying library files.
## Catalog identity — 20261012120001
Back up the database and **stop all writers** (web requests, import workers and
other database clients) before either upgrade or downgrade. Keep them stopped
through preflight and DDL. Preflight is not a concurrency lock.
Upgrade rejects duplicate managed paths and noncanonical paths, even without a
collision, reporting offending file IDs. It makes file ownership nullable with
RESTRICT on model deletion, replaces idx_files_model_path with unique
idx_files_managed_path and nonunique idx_files_model_id, and makes
idx_models_folder_name nonunique. Explicitly detach files before model deletion.
Rows, IDs, timestamps, dependent metadata and unrelated indexes/FKs are preserved.
Managed paths are lexical slash-separated relative names, preserved verbatim.
Empty/dot/dot-dot segments, backslashes, NUL, absolute paths and URI/drive prefixes
are rejected. Windows additionally rejects colons and trailing dots/spaces in
segments. Case and Unicode are not normalized. This is not filesystem case
comparison or symlink authorization; PathSafety still governs I/O. Migration
validation performs no filesystem access.
Investigate reported IDs and explicitly resolve catalog paths and ownership
against the library before retrying. No automatic merging, renaming, reassignment
or deletion repairs conflicts.
Downgrade to 20261011120006 rejects unassigned file IDs and duplicate same-folder
model titles before DDL. Explicitly assign files and disambiguate titles first.
Only then are NOT NULL, per-model path uniqueness, unique folder/title and the
old CASCADE model FK restored. Preflight failure leaves schema, data and migration
version unchanged.
Tasks 1–3 restore repository/importer compatibility and add the bounded catalog
service: explicit same-title identities, ambiguity-safe legacy lookup, nullable
registration and expected-owner associations using savepoint-backed transactions.
Association changes update only model_id, not physical files or home folders.
Task 4 verified the full suite on 2026-10-13:
- Windows PHP 8.3.35: foundation passed; PHPUnit 377 tests / 2032 assertions /
28 skips; standard lint passed (Composer script commands invoked directly).
- Fresh isolated PostgreSQL 17 / PHP 8.4.26 via `sh bin/test-postgres.sh`: Composer
tests 377 / 2160 / 7 skips and lint passed. Log on the Docker host:
`keith@192.168.2.23:/tmp/meshvault-catalog-tests-Ov96uNzU/pg-run-3.log`.
- The suite runs production migrations in generated schemas, including populated
down/up, dependent metadata, direct constraints, index preservation and unchanged
preflight-rejection snapshots. This does not replace an operator backup/restore
drill or deployment migration smoke acceptance.
- First PG run exposed four SQLite-trigger syntax errors in rollback tests;
dialect-specific test triggers now prove rollback after an actual earlier write.
Six new two-session cases verify 55P03 contention, stale/no-op behavior, ascending
execution parameters, nested rollback/lock release and root creation serialization.
They passed existing locking code; no induced deadlock or mutation-red claim.
No development database or historical workspace was touched. Independent full-slice
review approved this bounded implementation; #28 is not complete. Importer confirmation/recovery, broader
metadata/lifecycle contracts and unresolved M2/M3 acceptance remain open. See
`docs/project-status.md` for the evidence and limitations.
+39 -2
View File
@@ -4,7 +4,44 @@ Baseline: `c1ef2ee`. This review compares the live Gitea acceptance criteria wit
source, tests, and recorded runtime evidence. Implementation is not milestone
acceptance. The earlier assessment understated remaining M2/M3 work.
## Evidence
## Catalog-core Task 4 evidence — 2026-10-13
Tasks 1–3 were approved at handoff (371 tests / 2032 assertions / 22 skips,
local lint clean). Task 4 adds six PostgreSQL cases using independent sessions,
generated schemas, 250ms lock timeouts and 5s statement timeouts. Execution-time
parameter recording establishes ascending file locks for reversed input;
FOR SHARE contention establishes same-owner no-op locking. Tests also establish
stale-owner rejection, rollback/retry, nested transaction survival, lower-lock
release after savepoint rollback, and explicit/legacy root creation contention
including disjoint folders. No deadlock was induced and no automatic retry added.
- Fresh Windows PHP 8.3.35: foundation checks pass; full PHPUnit **377 tests,
2032 assertions, 28 skips**, exit 0; standard PHPCS lint exit 0. These are the
Composer script commands invoked directly with the required PHP executable.
- Fresh isolated Docker PostgreSQL 17 / PHP 8.4.26: `sh bin/test-postgres.sh`
ran full Composer tests and lint successfully: **377 tests, 2160 assertions,
7 skips**. Final log:
`keith@192.168.2.23:/tmp/meshvault-catalog-tests-Ov96uNzU/pg-run-3.log`.
- Initial PG run (`pg-run-1.log` in the same directory): 377 tests / 2140
assertions / 4 errors / 7 skips. All four errors were SQLite-only trigger DDL
in the later-update rollback fixture. A PostgreSQL trigger function now tests
the same earlier-write condition; `pg-run-2.log` first confirmed the fix.
New concurrency cases passed against the approved implementation; no production
locking gap or new-case red/mutation run is claimed.
- Transfer included current uncommitted source, excluding vendor, node_modules,
.git, .superpowers, environment files, secrets and key files. Dockerfile.test
installs dependencies. Dedicated disposable containers/network used no host DB
ports or persistent volumes; test containers were gone after final cleanup.
Neither `/tmp/MeshVault`, the historical PG workspace nor development data was
modified. SSH calls exceeded the tool's 30s wait; subsequent log reads verified
completion and the script's tests-and-lint success marker.
Independent whole-slice review approved the bounded implementation with no blocking
findings, reran local tests/lint and checked the PostgreSQL log and matching source
hashes. The user authorized local integration into main, not a push. No issue closure
is implied; broader #28 metadata/lifecycle contracts and the M2/M3 gaps remain open.
## Historical evidence
- Fresh Windows run: `composer test` — 291 tests, 1801 assertions, 22 skips; exit 0.
`composer lint` — exit 0. Expected HTTP failure-path logs are not test failures.
@@ -43,7 +80,7 @@ changes; issue states were rechecked through the live API afterward.
| #25 | Queue/worker/locking exist; confirmed grouping and full replay/retry guarantees remain incomplete. |
| #26 | Repository progress exists; caller-facing polling, full state distinctions and worker handling of empty jobs remain incomplete. |
| #27 | Failures persist; supported retry with attempt history and persisted-message redaction remain incomplete. |
| #28 | Importer-facing repository exists; explicit reassignment, catalog-owned transaction contract and broader metadata/lifecycle contracts remain incomplete. |
| #28 | Bounded identity, explicit expected-owner reassignment and catalog-owned savepoint transactions are implemented with local/PG evidence above; whole-slice review approved, but broader metadata/lifecycle contracts remain open. Not complete. |
## Important implementation findings
@@ -0,0 +1,103 @@
# Catalog Core Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Deliver the bounded #28 foundation: explicit model identity and atomic expected-owner file associations without changing physical storage.
**Architecture:** Extend the existing catalog repository and expose its savepoint-backed transaction boundary to a thin catalog service. New Phinx migrations enforce identity constraints; PostgreSQL row locks protect association decisions. SQLite remains the fast test backend.
**Tech Stack:** Existing PHP/PDO, Phinx, Slim/PHP-DI and PHPUnit; no new dependencies.
**Spec:** C:\Users\ksolo\Projects\3D Printing Stuff\MeshVault\docs\superpowers\specs\2026-10-12-catalog-core-design.md
## Global Constraints
- No new framework or runtime dependency.
- Add new migrations, never rewrite applied migrations.
- Preserve file IDs, bytes, paths and timestamps when associations change.
- Explicit null means unassigned, not no precondition.
- Use RESTRICT on model deletion and explicit detach.
- No browse UI, moves, physical deletion, downloads, duplicate decisions, retry engine or import crash-recovery fix in this first slice.
- Do not silently relabel unresolved M2/M3 acceptance as complete.
- Commit checkpoints require authorization; do not push automatically.
## Review Focus
- Migration/downgrade preflight must leave schema and data intact on failure (Task 1).
- Legacy ensureModel races after uniqueness removal require creation serialization (Tasks 2/4).
- Null ownership must never become zero or a wildcard (Tasks 2/3).
- Savepoint failures must preserve caller transactions and unrelated writes (Tasks 3/4).
- Reversed input and same-owner no-ops must not bypass locking (Task 4).
## File map
All paths below resolve against **C:\Users\ksolo\Projects\3D Printing Stuff\MeshVault**; use absolute paths when executing.
- Create `db/migrations/20261012120001_catalog_identity.php`: explicit up/down and safe preflights.
- Create `src/Catalog/ManagedPath.php`: lexical canonical-path validation shared by migration and repository.
- Modify `src/Repository/CatalogRepositoryInterface.php` and `src/Repository/PdoCatalogRepository.php`: identity, nullable registration, transaction boundary and associations.
- Reuse `src/Repository/PdoRepository.php`; modify only if failing transaction tests demonstrate a need.
- Create `src/Catalog/CatalogService.php`; register in `config/container.php` using the existing shared repository/PDO.
- Extend `tests/Integration/RepositoryDatabase.php` for isolated migration targets and rollback.
- Create `tests/Unit/Catalog/ManagedPathTest.php`, `tests/Integration/CatalogMigrationTest.php`, `tests/Integration/CatalogCoreTest.php`, `tests/Integration/PostgresCatalogTest.php`.
- Retain RepositoryTest and ImportPipelineTest as compatibility gates; update interface doubles wherever found.
- Update `db/migrations/README.md` and `docs/project-status.md` with observed scope/results.
## Task 1 — Safe identity migration
**Interfaces:** `ManagedPath::validate(string $path): string` returns unchanged input or throws InvalidArgumentException. Extend `RepositoryDatabase::migrate(string $path, ?int $target = null): PDO`; add `migrateTo(string $path, int $target): void` and `rollbackTo(string $path, int $target): void` using Phinx and the registered isolated connection/configuration.
**Path contract:** Nonempty slash-separated relative names; reject backslashes, NUL, URI syntax, leading slash/drive prefixes, empty segments, dot and dot-dot segments. Preserve case and Unicode spelling. Apply existing Win32 trailing-dot/space and colon restrictions on Windows. This is lexical identity, not symlink authorization or filesystem case equivalence; PathSafety still governs I/O. No filesystem access in migration validation.
- [ ] Write valid/invalid path table tests and migration tests starting at 20261011120006 with populated models, files and dependent metadata.
- [ ] Test preflight failure for duplicate managed paths across models and noncanonical paths, even without collision. Test down failure for unassigned files and duplicate same-folder titles. Compare rows, indexes/FKs and migration version before/after failure.
- [ ] Run `php vendor/phpunit/phpunit/phpunit --filter 'ManagedPathTest|CatalogMigrationTest'`; require failures for missing behavior, not harness errors.
- [ ] Implement validator and isolated migration helpers. Implement up/down preflights before DDL; require stopped writers during migration, documented in migration README. Report offending IDs and remediation guidance without repairing data.
- [ ] Up: nullable files.model_id with RESTRICT; replace idx_files_model_path with unique idx_files_managed_path and nonunique idx_files_model_id; make idx_models_folder_name nonunique. Down restores original constraints only after preflight. Preserve other indexes/FKs/columns across SQLite rebuilds.
- [ ] Test direct SQL constraints, including unique paths with null owners, invalid model FK, deletion restriction and detached file survival. Verify IDs, timestamps, dependent rows and subsequent generated IDs survive clean down/up.
- [ ] Run targeted SQLite tests and isolated PostgreSQL tests, then `composer lint`; review checkpoint before any authorized commit.
## Task 2 — Explicit identity and importer compatibility
**Interfaces:** Add `atomic(callable $operation): mixed` and `createModel(string $folderPath, string $modelName): int` to CatalogRepositoryInterface. Make recordFile's first parameter `?int $modelId`; retain other parameters/defaults. Preserve aggregate's model/folder/files array and null-on-missing behavior.
- [ ] Write CatalogCoreTest cases for empty models, independent same-title models and required home folder. Legacy ensureModel creates on zero matches, reuses one and throws DomainException on multiple matches. Reject blank titles and titles exceeding 255 characters without trimming stored titles or adding an extension dependency.
- [ ] Test nullable registration, canonical path validation, strict nullable ownership comparisons, same owner/hash/size idempotence and conflicting path rejection. Preserve existing status/hash behavior.
- [ ] Run `php vendor/phpunit/phpunit/phpunit --filter CatalogCoreTest`; confirm intended failures.
- [ ] Implement atomic using the existing savepoint helper. Both creation methods acquire the existing root-folder writer lock before lookup/insert inside a transaction. Explicit creation always inserts; legacy lookup counts matches instead of using ON CONFLICT on the removed constraint. Reject empty home-folder input; retain existing slash-root/folder conventions.
- [ ] Implement nullable registration and shared path validation before writes; retain recordFile's writer lock and database-backed path identity. Never adopt another owner's record implicitly.
- [ ] Adapt interface doubles found by search; run CatalogCoreTest, RepositoryTest, ImportPipelineTest and lint. Review checkpoint before authorized commit.
## Task 3 — Atomic associations and service boundary
**Repository interface:** `changeFileOwner(array $fileIds, ?int $expectedOwnerId, ?int $targetOwnerId): void`. Require a nonempty list of distinct positive integers; non-null owner IDs must be positive. Invalid arguments throw InvalidArgumentException; missing models/files or stale ownership throw DomainException. SQL/FK/lock failures remain PDOException; no automatic retry.
**Service interface:** Constructor takes CatalogRepositoryInterface. Expose `createModel(string $folderPath, string $modelName): int`, `aggregate(int $modelId): ?array`, `assignFiles(int $modelId, array $fileIds): void` (expected null), `reassignFiles(int $expectedModelId, int $targetModelId, array $fileIds): void`, `detachFiles(int $expectedModelId, array $fileIds): void`. Use repository atomic for command orchestration, never a separate PDO/transaction manager.
- [ ] Test aggregate with two STL parts, selected image/document and files spanning folders. Reassignment leaves empty models valid; home folders stay unchanged. Compare all file fields except model_id, physical bytes, paths and mtimes across association operations.
- [ ] Test empty/duplicate/noninteger/nonpositive selections, missing expected/target model, missing file mixed with valid IDs, stale/null expected ownership and same-owner no-op.
- [ ] Test nested success leaves the caller transaction active and caller rollback undoes changes. Caught failure preserves unrelated outer writes. Use test-only database triggers to fail a later update and prove earlier updates roll back too.
- [ ] Run CatalogCoreTest; confirm intended red tests.
- [ ] Implement transactional validation: check referenced models, sort IDs numerically, acquire each file row's FOR UPDATE lock in ascending order on PostgreSQL; omit lock syntax on SQLite. Check every selected row/owner under lock before any update. Update only model_id. Same-owner no-op still locks/checks; concurrent target deletion is constrained by FK locking or causes rollback.
- [ ] Implement service and DI registration, with container resolution coverage following existing tests. Keep importer using compatible repository methods, not a fictitious confirmed-manifest workflow.
- [ ] Run CatalogCoreTest, RepositoryTest, ImportPipelineTest and lint; review checkpoint before authorized commit.
## Task 4 — PostgreSQL contention and acceptance evidence
Follow PostgresRepositoryTest: generated schema, two connections, lock_timeout 250ms, statement_timeout 5s, finally rollback/cleanup. Configured failures must error rather than skip.
- [ ] Write contest tests: A changes ownership in an uncommitted transaction; B times out with SQLSTATE 55P03 and no changes. After A commits, B's stale owner fails rather than overwrites. If A rolls back instead, B may succeed.
- [ ] Hold FOR SHARE in A; B's same-owner no-op must block/fail. Nested lock failure preserves B's caller transaction and unrelated writes.
- [ ] Verify ascending lock parameters for reversed input with a test-only PDOStatement execution recorder. Hold the higher-ID row in A; B must time out without partial updates or leaked lower-row locks after savepoint rollback. Timeout alone does not prove ordering.
- [ ] Test root-lock contention for explicit and legacy creation; after release ensureModel reuses one match or rejects ambiguity. Preserve lock/deadlock SQLSTATE; no automatic retries or claims of reproduced deadlocks.
- [ ] Run new PostgreSQL tests red, fix demonstrated gaps, then full `composer test` and `composer lint` locally. Record fresh totals/skips.
- [ ] Run `sh bin/test-postgres.sh` from a fresh isolated checkout containing these changes on the Docker host. Never touch dirty /tmp/MeshVault or development data. Record results/evidence path; inability to run is a blocker, not a pass.
- [ ] Run `git diff --check`, inspect/reread changed files and update migration guidance/status with verified results only. Broader #28 metadata/lifecycle contracts and unresolved M2/M3 acceptance remain open.
- [ ] Whole-slice review before authorized commit/push or tracker acceptance changes.
## Self-review and handoff
Coverage checked: migration/path safety (Task 1), identity/home folder/legacy compatibility (Task 2), multipart associations/nested rollback (Task 3), PostgreSQL concurrency (Task 4). Nullable-owner signatures agree. Import confirmation, removal service, UI and other non-goals remain deferred.
Execution status: implemented with subagents and approved by task-level and independent whole-slice review. Local verification: 377 tests, 2032 assertions, 28 skips; isolated PostgreSQL verification: 377 tests, 2160 assertions, 7 skips; foundation and lint passed. The checklist above preserves the original execution prescription, not a current pending-work list. PostgreSQL tests exposed and drove a trigger portability fix; the concurrency cases passed the existing implementation without a mutation-red run, and no deadlock was induced. The user authorized local integration into main; no push is authorized. Broader #28 and M2/M3 acceptance remain open.
@@ -1,6 +1,6 @@
# Catalog core foundation — proposed design
Status: draft for user approval; no product implementation authorized by this document.
Status: domain rules and implementation plan approved by the user; bounded catalog-core implementation completed and independently reviewed. Broader #28 and M2/M3 acceptance remain open.
## Intent
@@ -23,7 +23,7 @@ No browse UI, moves, physical deletion, downloads, duplicate decisions, retry en
or import crash-recovery fix in this first slice. Do not silently relabel unresolved
M2/M3 acceptance as complete. No new framework or runtime dependency.
## Proposed domain rules (require approval)
## Approved domain rules
1. A physical managed file has one durable catalog identity and belongs to at most
one logical model. Unassigned files retain their file record. Shared ownership is
@@ -104,5 +104,7 @@ and serialized folder/model moves (#31/#32) follow as separately designed slices
## Approval boundary
The ownership/unassigned-file/empty-model rules above are proposals, not existing
user decisions. Approve or revise them before creating the implementation plan.
The user approved these rules as the basis for the implementation plan, including
single ownership, persistent unassigned files, empty models, independent same-title
models and a required organizational home folder. The bounded plan was approved,
implemented and independently reviewed; this does not mark #28 or M2/M3 complete.
+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);
+385
View File
@@ -0,0 +1,385 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use DomainException;
use InvalidArgumentException;
use MeshVault\Repository\PdoCatalogRepository;
use PDO;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class CatalogCoreTest extends TestCase
{
private PDO $pdo;
private PdoCatalogRepository $catalog;
private string $database;
protected function setUp(): void
{
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-core-');
$this->pdo = RepositoryDatabase::migrate($this->database);
$this->catalog = new PdoCatalogRepository($this->pdo);
}
protected function tearDown(): void
{
unset($this->catalog, $this->pdo);
gc_collect_cycles();
RepositoryDatabase::cleanup($this->database);
}
public function testExplicitIdentityAndLegacyCountedLookup(): void
{
$first = $this->catalog->ensureModel('/parts', ' Plane ');
self::assertSame($first, $this->catalog->ensureModel('/parts', ' Plane '));
$second = $this->catalog->createModel('/parts', ' Plane ');
self::assertNotSame($first, $second);
foreach ([$first, $second] as $id) {
$aggregate = $this->catalog->aggregate($id);
self::assertSame(' Plane ', $aggregate['model']['name']);
self::assertSame('/parts', $aggregate['folder']['path']);
self::assertSame([], $aggregate['files']);
}
self::assertNull($this->catalog->aggregate(999999));
try {
$this->catalog->ensureModel('/parts', ' Plane ');
self::fail('Ambiguous legacy lookup must fail');
} catch (DomainException) {
self::assertSame(2, $this->countRows('models'));
}
}
#[DataProvider('invalidModels')]
public function testInvalidModelInputsWriteNothing(string $folder, string $title): void
{
foreach (['createModel', 'ensureModel'] as $method) {
try {
$this->catalog->$method($folder, $title);
self::fail('Invalid model must fail');
} catch (InvalidArgumentException) {
self::assertSame(0, $this->countRows('models'));
self::assertSame(0, $this->countRows('folders'));
}
}
}
public static function invalidModels(): array
{
return [['', 'Title'], ['/', ''], ['/', " \t\n"], ['/', str_repeat('a', 256)],
['/', str_repeat('é', 256)], ['../escape', 'Title']];
}
public function testUnicodeTitleLimitCountsCharactersAndRootIsValid(): void
{
foreach (['createModel', 'ensureModel'] as $method) {
$id = $this->catalog->$method('/', str_repeat('é', 255));
self::assertSame(str_repeat('é', 255), $this->catalog->aggregate($id)['model']['name']);
self::assertSame('/', $this->catalog->aggregate($id)['folder']['path']);
}
}
public function testNullableRegistrationIsIdempotentWithoutChangingMetadata(): void
{
$id = $this->catalog->recordFile(null, 'parts/é.stl', 'hash', 12, 'model/stl', 'Missing');
$before = $this->pdo->query('SELECT * FROM files')->fetch(PDO::FETCH_ASSOC);
self::assertNull($before['model_id']);
self::assertSame($id, $this->catalog->recordFile(null, 'parts/é.stl', 'hash', 12));
self::assertSame($before, $this->pdo->query('SELECT * FROM files')->fetch(PDO::FETCH_ASSOC));
$model = $this->catalog->createModel('/elsewhere', 'Title');
$owned = $this->catalog->recordFile($model, 'other.stl', 'hash', 12);
self::assertSame($owned, $this->catalog->recordFile($model, 'other.stl', 'hash', 12));
self::assertSame('/elsewhere', $this->catalog->aggregate($model)['folder']['path']);
}
#[DataProvider('registrationConflicts')]
public function testRegistrationNeverAdoptsOrOverwrites(string $conflict): void
{
$model = $this->catalog->ensureModel('/', 'Title');
$owner = $conflict === 'detach' ? $model : null;
$this->catalog->recordFile($owner, 'part.stl', 'hash', 12);
$before = $this->pdo->query('SELECT * FROM files')->fetchAll(PDO::FETCH_ASSOC);
try {
$this->catalog->recordFile(
$conflict === 'adopt' ? $model : null,
'part.stl',
$conflict === 'hash' ? 'different' : 'hash',
$conflict === 'size' ? 13 : 12
);
self::fail('Conflicting registration must fail');
} catch (DomainException) {
self::assertSame($before, $this->pdo->query('SELECT * FROM files')->fetchAll(PDO::FETCH_ASSOC));
}
}
public static function registrationConflicts(): array
{
return [['adopt'], ['detach'], ['hash'], ['size']];
}
#[DataProvider('invalidRegistrations')]
public function testInvalidRegistrationWritesNothing(?int $owner, string $path, string $hash, int $size): void
{
try {
$this->catalog->recordFile($owner, $path, $hash, $size);
self::fail('Invalid registration must fail');
} catch (InvalidArgumentException) {
self::assertSame(0, $this->countRows('files'));
self::assertSame(0, $this->countRows('folders'));
}
}
public static function invalidRegistrations(): array
{
$cases = [];
foreach (['', '/absolute', 'a\\b', 'a//b', 'a/./b', 'a/../b', "a\0b", 'https://host/a'] as $path) {
$cases[] = [null, $path, 'hash', 1];
}
return array_merge($cases, [[0, 'a', 'hash', 1], [-1, 'a', 'hash', 1],
[null, 'a', '', 1], [null, 'a', 'hash', -1]]);
}
public function testAtomicReturnsValuesAndPreservesCallerTransaction(): void
{
$this->pdo->beginTransaction();
$outer = $this->catalog->ensureModel('/', 'Outer');
$id = $this->catalog->atomic(fn () => $this->catalog->createModel('/', 'Inner'));
self::assertNotSame($outer, $id);
self::assertTrue($this->pdo->inTransaction());
try {
$this->catalog->atomic(function (): void {
$this->catalog->createModel('/rollback', 'Rollback');
$this->catalog->recordFile(null, 'rollback/file.stl', 'hash', 1);
throw new RuntimeException('abort');
});
self::fail('Expected failure');
} catch (RuntimeException $error) {
self::assertSame('abort', $error->getMessage());
}
self::assertTrue($this->pdo->inTransaction());
self::assertSame(2, $this->countRows('models'));
self::assertSame(1, $this->countRows('folders'));
self::assertSame(0, $this->countRows('files'));
$this->pdo->rollBack();
self::assertSame(0, $this->countRows('models'));
self::assertSame('result', $this->catalog->atomic(static fn () => 'result'));
self::assertFalse($this->pdo->inTransaction());
}
#[DataProvider('invalidAssociations')]
public function testInvalidAssociationArguments(array $ids, ?int $expected, ?int $target): void
{
try {
$this->catalog->changeFileOwner($ids, $expected, $target);
self::fail('Invalid association must fail');
} catch (InvalidArgumentException) {
self::assertSame(0, $this->countRows('files'));
self::assertFalse($this->pdo->inTransaction());
}
}
public static function invalidAssociations(): array
{
return [[[], null, 1], [[1, 1], null, 1], [['1'], null, 1], [[1.0], null, 1],
[[true], null, 1], [[null], null, 1], [[0], null, 1], [[-1], null, 1],
[[1], 0, 1], [[1], -1, 1], [[1], null, 0], [[1], null, -1], [[2 => 1], null, 1]];
}
#[DataProvider('associationConflicts')]
public function testAssociationConflictsPreserveEveryRow(string $case): void
{
$first = $this->catalog->createModel('/first', 'First');
$second = $this->catalog->createModel('/second', 'Second');
$one = $this->catalog->recordFile($first, 'one.stl', 'one', 1);
$two = $this->catalog->recordFile($case === 'mixed-owner' ? $second : $first, 'two.stl', 'two', 2);
$before = $this->fileRows();
$expected = match ($case) {
'missing-expected' => 999999,
'null-expected' => null,
'stale-expected' => $second,
default => $first,
};
try {
$this->catalog->changeFileOwner(
[$one, $case === 'missing-file' ? 999999 : $two],
$expected,
$case === 'missing-target' ? 999999 : $second
);
self::fail('Conflict must fail');
} catch (DomainException) {
self::assertSame($before, $this->fileRows());
self::assertFalse($this->pdo->inTransaction());
}
}
public static function associationConflicts(): array
{
return array_map(static fn (string $case): array => [$case], [
'missing-expected', 'missing-target', 'missing-file', 'null-expected', 'stale-expected', 'mixed-owner',
]);
}
public function testNestedAssociationSuccessRemainsOwnedByCaller(): void
{
$service = new \MeshVault\Catalog\CatalogService($this->catalog);
$model = $service->createModel('/', 'Target');
$file = $this->catalog->recordFile(null, 'one.stl', 'hash', 1);
$before = $this->fileRows();
$this->pdo->beginTransaction();
$service->assignFiles($model, [$file]);
self::assertTrue($this->pdo->inTransaction());
self::assertSame($model, (int) $this->fileRows()[0]['model_id']);
$this->pdo->rollBack();
self::assertSame($before, $this->fileRows());
}
#[DataProvider('failureTransactions')]
public function testLaterUpdateFailureRollsBackEarlierUpdates(bool $nested, bool $throughService): void
{
$service = new \MeshVault\Catalog\CatalogService($this->catalog);
$model = $service->createModel('/', 'Target');
$one = $this->catalog->recordFile(null, 'one.stl', 'one', 1);
$two = $this->catalog->recordFile(null, 'two.stl', 'two', 2);
$before = $this->fileRows();
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql') {
$this->pdo->exec('CREATE FUNCTION reject_later_update() RETURNS trigger LANGUAGE plpgsql AS $$'
. ' BEGIN IF NEW.id = ' . $two
. ' AND (SELECT model_id FROM files WHERE id = ' . $one . ') = ' . $model
. " THEN RAISE EXCEPTION 'later update rejected'; END IF; RETURN NEW; END; $$");
$this->pdo->exec('CREATE TRIGGER fail_later BEFORE UPDATE OF model_id ON files'
. ' FOR EACH ROW EXECUTE FUNCTION reject_later_update()');
} else {
$this->pdo->exec('CREATE TRIGGER fail_later BEFORE UPDATE OF model_id ON files WHEN NEW.id = ' . $two
. ' AND (SELECT model_id FROM files WHERE id = ' . $one . ') = ' . $model
. " BEGIN SELECT RAISE(ABORT, 'later update rejected'); END");
}
if ($nested) {
$this->pdo->beginTransaction();
$outer = $service->createModel('/outer', 'Outer');
}
try {
if ($throughService) {
$service->assignFiles($model, [$two, $one]);
} else {
$this->catalog->changeFileOwner([$two, $one], null, $model);
}
self::fail('Later SQL update must fail');
} catch (\PDOException $error) {
self::assertStringContainsString('later update rejected', $error->getMessage());
}
self::assertSame($before, $this->fileRows());
self::assertSame($nested, $this->pdo->inTransaction());
if ($nested) {
self::assertNotNull($service->aggregate($outer));
$this->pdo->commit();
self::assertNotNull($service->aggregate($outer));
}
}
public static function failureTransactions(): array
{
return [[false, false], [true, false], [false, true], [true, true]];
}
public function testServiceAssociationsPreservePhysicalFilesAndMetadata(): void
{
$service = new \MeshVault\Catalog\CatalogService($this->catalog);
$first = $service->createModel('/home', 'Multipart');
$second = $service->createModel('/other-home', 'Destination');
$root = dirname(__DIR__, 2) . '/.superpowers/catalog-core/files-' . bin2hex(random_bytes(6));
mkdir($root, 0777, true);
$paths = ['parts/one.stl', 'parts/two.stl', 'images/selected.png', 'docs/selected.pdf', 'docs/unselected.pdf'];
$ids = $physical = [];
try {
foreach ($paths as $path) {
if (!is_dir(dirname($root . '/' . $path))) {
mkdir(dirname($root . '/' . $path));
}
$bytes = "fixture\0" . $path;
file_put_contents($root . '/' . $path, $bytes);
touch($root . '/' . $path, 1000000000);
$physical[$path] = [file_get_contents($root . '/' . $path), filemtime($root . '/' . $path)];
$ids[] = $this->catalog->recordFile(null, $path, hash('sha256', $bytes), strlen($bytes));
}
$before = $this->fileRows();
$selected = array_slice($ids, 0, 4);
$service->assignFiles($first, array_reverse($selected));
self::assertSame($selected, array_column($service->aggregate($first)['files'], 'id'));
$this->catalog->changeFileOwner($selected, $first, $first);
$service->reassignFiles($first, $second, $selected);
self::assertSame([], $service->aggregate($first)['files']);
self::assertSame('/home', $service->aggregate($first)['folder']['path']);
self::assertSame('/other-home', $service->aggregate($second)['folder']['path']);
self::assertSame($selected, array_column($service->aggregate($second)['files'], 'id'));
$after = $this->fileRows();
foreach ($after as $index => $row) {
self::assertSame($index < 4 ? $second : null, $row['model_id']);
$row['model_id'] = null;
self::assertSame($before[$index], $row);
}
$service->detachFiles($second, $selected);
$this->catalog->changeFileOwner($selected, null, null);
self::assertSame($before, $this->fileRows());
self::assertSame([], $service->aggregate($second)['files']);
self::assertNull($service->aggregate(999999));
foreach ($physical as $path => $snapshot) {
clearstatcache(true, $root . '/' . $path);
self::assertSame($snapshot, [
file_get_contents($root . '/' . $path), filemtime($root . '/' . $path),
]);
}
} finally {
foreach ($paths as $path) {
if (is_file($root . '/' . $path)) {
unlink($root . '/' . $path);
}
}
foreach (['parts', 'images', 'docs'] as $folder) {
if (is_dir($root . '/' . $folder)) {
rmdir($root . '/' . $folder);
}
}
rmdir($root);
}
}
public function testContainerResolvesServiceWithSharedRepositoryAndPdo(): void
{
$config = \MeshVault\Config\AppConfig::fromEnvironment([
'MESHVAULT_LIBRARY_DIR' => dirname(__DIR__, 2), 'MESHVAULT_DB_HOST' => 'unused',
'MESHVAULT_DB_NAME' => 'unused', 'MESHVAULT_DB_USER' => 'unused',
'MESHVAULT_DB_PASSWORD' => 'unused',
]);
$definitions = require dirname(__DIR__, 2) . '/config/container.php';
$builder = new \DI\ContainerBuilder();
$builder->useAutowiring(false);
$builder->addDefinitions($definitions($config));
$builder->addDefinitions([PDO::class => $this->pdo]);
$container = $builder->build();
self::assertTrue($container->has(\MeshVault\Catalog\CatalogService::class));
$service = $container->get(\MeshVault\Catalog\CatalogService::class);
$repository = $container->get(\MeshVault\Repository\CatalogRepositoryInterface::class);
self::assertSame($repository, (new \ReflectionObject($service))->getProperty('catalog')->getValue($service));
$this->pdo->beginTransaction();
$model = $service->createModel('/', 'Container');
$file = $repository->recordFile(null, 'container.stl', 'hash', 1);
$service->assignFiles($model, [$file]);
self::assertCount(1, $service->aggregate($model)['files']);
self::assertTrue($this->pdo->inTransaction());
$this->pdo->rollBack();
self::assertNull($service->aggregate($model));
}
private function fileRows(): array
{
return $this->pdo->query('SELECT * FROM files ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
}
private function countRows(string $table): int
{
return (int) $this->pdo->query('SELECT COUNT(*) FROM ' . $table)->fetchColumn();
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use ArrayObject;
use PDOStatement;
/** Records execution, including the lock attempt that throws, rather than just SQL preparation. */
final class CatalogLockStatement extends PDOStatement
{
protected function __construct(private ArrayObject $locks)
{
}
public function execute(?array $params = null): bool
{
if (preg_match('/SELECT .* FROM files WHERE id = \? FOR UPDATE$/i', $this->queryString)) {
$this->locks[] = $params;
}
return parent::execute($params);
}
}
+202
View File
@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use PDO;
use PDOException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class CatalogMigrationTest extends TestCase
{
#[DataProvider('badPaths')]
public function testUpgradePreflightLeavesEverythingIntact(string $path, bool $duplicate): void
{
if ($duplicate) {
$this->pdo->exec("INSERT INTO files (id, model_id, folder_id, name, managed_path)
VALUES (31, 21, 10, 'other', 'parts/part.stl')");
} else {
$statement = $this->pdo->prepare('UPDATE files SET managed_path = ? WHERE id = 30');
$statement->execute([$path]);
}
$this->assertPreflightFails(false, $duplicate ? ['30', '31'] : ['30']);
}
public static function badPaths(): array
{
return [['parts/part.stl', true], ['a/../part.stl', false], ['a//part.stl', false], ['C:/part.stl', false]];
}
#[DataProvider('downgradeConflicts')]
public function testDowngradePreflightLeavesEverythingIntact(bool $unassigned): void
{
RepositoryDatabase::migrateTo($this->path, self::NEW);
if ($unassigned) {
$this->pdo->exec('UPDATE files SET model_id = NULL WHERE id = 30');
} else {
$this->pdo->exec("UPDATE models SET name = 'First' WHERE id = 21");
}
$this->assertPreflightFails(true, $unassigned ? ['30'] : ['20', '21']);
}
public static function downgradeConflicts(): array
{
return [[true], [false]];
}
private function assertPreflightFails(bool $down, array $ids): void
{
$before = $this->snapshot();
try {
if ($down) {
RepositoryDatabase::rollbackTo($this->path, self::OLD);
} else {
RepositoryDatabase::migrateTo($this->path, self::NEW);
}
self::fail('Expected preflight rejection');
} catch (RuntimeException $error) {
foreach ($ids as $id) {
self::assertStringContainsString($id, $error->getMessage());
}
self::assertStringContainsString('Resolve', $error->getMessage());
}
self::assertSame($before, $this->snapshot());
}
private function rows(): array
{
$rows = [];
$tables = ['folders', 'models', 'files', 'tags', 'model_tags', 'thumbnails', 'import_jobs', 'import_items'];
foreach ($tables as $table) {
$rows[$table] = $this->pdo->query('SELECT * FROM ' . $table . ' ORDER BY 1')->fetchAll(PDO::FETCH_ASSOC);
}
return $rows;
}
private function indexes(): array
{
$indexes = [];
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') {
foreach (['files', 'models'] as $table) {
foreach ($this->pdo->query('PRAGMA index_list(' . $table . ')')->fetchAll(PDO::FETCH_ASSOC) as $row) {
$indexes[$table][$row['name']] = (int) $row['unique'];
}
ksort($indexes[$table]);
}
}
return $indexes;
}
private function snapshot(): array
{
$snapshot = [$this->rows(), $this->pdo->query('SELECT * FROM phinxlog ORDER BY version')->fetchAll()];
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') {
$snapshot[] = $this->pdo->query('SELECT * FROM sqlite_master ORDER BY type, name')->fetchAll();
foreach (['files', 'models'] as $table) {
$snapshot[] = $this->pdo->query('PRAGMA foreign_key_list(' . $table . ')')->fetchAll();
$snapshot[] = $this->pdo->query('PRAGMA index_list(' . $table . ')')->fetchAll();
}
}
return $snapshot;
}
private function assertRejected(string $sql): void
{
try {
$this->pdo->exec($sql);
self::fail('Expected database constraint rejection');
} catch (PDOException) {
self::assertTrue(true);
}
}
private const OLD = 20261011120006;
private const NEW = 20261012120001;
private string $directory;
private string $path;
private PDO $pdo;
protected function setUp(): void
{
$this->directory = RepositoryDatabase::directory('catalog-migration');
$this->path = $this->directory . '/catalog.sqlite';
$this->pdo = RepositoryDatabase::migrate($this->path, self::OLD);
$this->pdo->exec("INSERT INTO folders (id, name, path) VALUES (10, 'root', '/')");
$this->pdo->exec("INSERT INTO models (id, folder_id, name, notes) VALUES
(20, 10, 'First', 'keep'), (21, 10, 'Second', 'also keep')");
$this->pdo->exec("INSERT INTO files (id, model_id, folder_id, name, managed_path, hash, size)
VALUES (30, 20, 10, 'part.stl', 'parts/part.stl', 'abc', 123)");
// Explicit fixture IDs do not advance PostgreSQL sequences.
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql') {
foreach (['folders' => 10, 'models' => 21, 'files' => 30] as $table => $id) {
$this->pdo->query("SELECT setval(pg_get_serial_sequence('$table', 'id'), $id, true)")->closeCursor();
}
}
$this->pdo->exec("INSERT INTO tags (id, name) VALUES (40, 'tag')");
$this->pdo->exec('INSERT INTO model_tags (model_id, tag_id) VALUES (20, 40)');
$this->pdo->exec("INSERT INTO thumbnails (model_id, managed_path, hash, size, mime_type)
VALUES (20, 'thumb.png', 'thumbhash', 12, 'image/png')");
$this->pdo->exec("INSERT INTO import_jobs (id, source_path) VALUES (50, 'source')");
$this->pdo->exec("INSERT INTO import_items (job_id, source_path, model_id) VALUES (50, 'part.stl', 20)");
}
protected function tearDown(): void
{
unset($this->pdo);
RepositoryDatabase::cleanup($this->path);
RepositoryDatabase::remove($this->directory);
}
public function testRoundTripPreservesRowsAndConstraints(): void
{
$before = $this->rows();
$oldIndexes = $this->indexes();
RepositoryDatabase::migrateTo($this->path, self::NEW);
$newIndexes = $this->indexes();
if ($newIndexes !== []) {
self::assertSame(1, $newIndexes['files']['idx_files_managed_path']);
self::assertSame(0, $newIndexes['files']['idx_files_model_id']);
self::assertSame(0, $newIndexes['models']['idx_models_folder_name']);
foreach (['idx_files_hash', 'idx_files_status', 'idx_files_folder_id'] as $index) {
self::assertSame($oldIndexes['files'][$index], $newIndexes['files'][$index]);
}
self::assertSame(
$oldIndexes['models']['idx_models_added_at'],
$newIndexes['models']['idx_models_added_at']
);
}
self::assertSame($before, $this->rows());
$this->assertRejected("INSERT INTO files (model_id, folder_id, name, managed_path)
VALUES (NULL, 10, 'duplicate', 'parts/part.stl')");
$this->assertRejected('UPDATE files SET model_id = 999 WHERE id = 30');
$this->assertRejected('DELETE FROM models WHERE id = 20');
$this->assertRejected('DELETE FROM folders WHERE id = 10');
$this->pdo->exec('UPDATE files SET model_id = NULL WHERE id = 30');
$this->assertRejected("INSERT INTO files (model_id, folder_id, name, managed_path)
VALUES (NULL, 10, 'duplicate', 'parts/part.stl')");
$this->pdo->exec('UPDATE files SET model_id = 20 WHERE id = 30');
RepositoryDatabase::rollbackTo($this->path, self::OLD);
self::assertSame($oldIndexes, $this->indexes());
self::assertSame($before, $this->rows());
$this->assertRejected('UPDATE files SET model_id = NULL WHERE id = 30');
$this->assertRejected("INSERT INTO models (folder_id, name) VALUES (10, 'First')");
RepositoryDatabase::migrateTo($this->path, self::NEW);
self::assertSame($before, $this->rows());
$model = $this->pdo->query("INSERT INTO models (folder_id, name) VALUES (10, 'First') RETURNING id");
self::assertGreaterThan(21, (int) $model->fetchColumn());
$model->closeCursor();
$file = $this->pdo->query("INSERT INTO files (model_id, folder_id, name, managed_path)
VALUES (NULL, 10, 'new', 'new.stl') RETURNING id");
self::assertGreaterThan(30, (int) $file->fetchColumn());
$file->closeCursor();
$this->pdo->exec('UPDATE files SET model_id = NULL WHERE id = 30');
$this->pdo->exec('DELETE FROM models WHERE id = 20');
self::assertSame(2, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') {
self::assertSame([], $this->pdo->query('PRAGMA foreign_key_check')->fetchAll());
}
}
}
+195
View File
@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use DomainException;
use MeshVault\Repository\PdoCatalogRepository;
use PDO;
use PDOException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class PostgresCatalogTest extends TestCase
{
private string $database;
private PDO $pdo;
private PDO $other;
private PdoCatalogRepository $catalog;
private PdoCatalogRepository $contender;
private \ArrayObject $locks;
protected function setUp(): void
{
if (!RepositoryDatabase::postgresConfigured()) {
self::markTestSkipped('Set MESHVAULT_TEST_PG_DSN to run PostgreSQL catalog contention tests.');
}
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-pg-catalog-');
$this->pdo = RepositoryDatabase::migrate($this->database);
$this->other = RepositoryDatabase::connect($this->database);
foreach ([$this->pdo, $this->other] as $pdo) {
$pdo->exec("SET lock_timeout = '250ms'");
$pdo->exec("SET statement_timeout = '5s'");
}
$this->locks = new \ArrayObject();
$this->other->setAttribute(PDO::ATTR_STATEMENT_CLASS, [CatalogLockStatement::class, [$this->locks]]);
$this->catalog = new PdoCatalogRepository($this->pdo);
$this->contender = new PdoCatalogRepository($this->other);
self::assertNotSame(
$this->pdo->query('SELECT pg_backend_pid()')->fetchColumn(),
$this->other->query('SELECT pg_backend_pid()')->fetchColumn(),
);
self::assertSame(
$this->pdo->query('SELECT current_schema()')->fetchColumn(),
$this->other->query('SELECT current_schema()')->fetchColumn(),
);
}
protected function tearDown(): void
{
foreach (['pdo', 'other'] as $property) {
if (isset($this->$property) && $this->$property->inTransaction()) {
$this->$property->rollBack();
}
}
unset($this->catalog, $this->contender, $this->pdo, $this->other);
if (isset($this->database)) {
RepositoryDatabase::cleanup($this->database);
}
}
private function fixture(): array
{
$source = $this->catalog->createModel('/parts', 'Source');
$target = $this->catalog->createModel('/parts', 'Target');
$low = $this->catalog->recordFile($source, 'parts/low.stl', 'low', 3);
$high = $this->catalog->recordFile($source, 'parts/high.stl', 'high', 4);
return [$source, $target, $low, $high];
}
private function timeout(callable $operation): void
{
try {
$operation();
self::fail('Expected a PostgreSQL lock timeout, not a successful write or no-op.');
} catch (PDOException $error) {
self::assertSame('55P03', $error->getCode());
}
}
private function files(): array
{
return $this->other->query('SELECT * FROM files ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
}
#[DataProvider('transactionOutcomes')]
public function testUncommittedOwnerBlocksThenCommitRejectsStaleOrRollbackAllowsRetry(bool $commit): void
{
[$source, $target, $low, $high] = $this->fixture();
$before = $this->files();
$this->pdo->beginTransaction();
$this->catalog->changeFileOwner([$low, $high], $source, $target);
$this->timeout(fn () => $this->contender->changeFileOwner([$high, $low], $source, null));
self::assertFalse($this->other->inTransaction());
self::assertSame($before, $this->files());
if ($commit) {
$this->pdo->commit();
$committed = $this->files();
try {
$this->contender->changeFileOwner([$high, $low], $source, null);
self::fail('Stale expected ownership must not overwrite the winner.');
} catch (DomainException $error) {
self::assertSame('File ownership has changed.', $error->getMessage());
}
self::assertSame($committed, $this->files());
self::assertSame([$target, $target], array_column($committed, 'model_id'));
} else {
$this->pdo->rollBack();
$this->contender->changeFileOwner([$high, $low], $source, null);
self::assertSame([null, null], array_column($this->files(), 'model_id'));
}
self::assertFalse($this->other->inTransaction());
}
#[DataProvider('associationTargets')]
public function testReversedLocksAndNestedFailureReleaseLowerLockWithoutPartialWrites(bool $noop): void
{
[$source, $target, $low, $high] = $this->fixture();
$before = $this->files();
$this->pdo->beginTransaction();
$held = $this->pdo->prepare('SELECT id FROM files WHERE id = ? FOR SHARE');
$held->execute([$high]);
$held->closeCursor();
$this->other->beginTransaction();
$this->other->exec("INSERT INTO tags (name) VALUES ('outer survives')");
$this->timeout(fn () => $this->contender->changeFileOwner(
[$high, $low],
$source,
$noop ? $source : $target,
));
// A timeout alone cannot establish order, especially when no UPDATE would occur.
self::assertSame([[$low], [$high]], $this->locks->getArrayCopy());
self::assertTrue($this->other->inTransaction());
self::assertSame($before, $this->files());
self::assertSame('outer survives', $this->other->query('SELECT name FROM tags')->fetchColumn());
// NOWAIT proves the lower lock was released by B's savepoint rollback, not B's transaction end.
$released = $this->pdo->prepare('SELECT id FROM files WHERE id = ? FOR UPDATE NOWAIT');
$released->execute([$low]);
self::assertSame($low, (int) $released->fetchColumn());
$released->closeCursor();
$this->pdo->rollBack();
$this->contender->changeFileOwner([$high, $low], $source, $noop ? $source : $target);
self::assertTrue($this->other->inTransaction());
self::assertSame(
[$noop ? $source : $target, $noop ? $source : $target],
array_column($this->files(), 'model_id'),
);
$this->other->rollBack();
self::assertSame($before, $this->files());
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM tags')->fetchColumn());
}
public static function associationTargets(): array
{
return ['reassignment' => [false], 'same-owner no-op' => [true]];
}
#[DataProvider('creationMethods')]
public function testRootSerializesCreationAndLegacyLookupAfterRelease(string $method): void
{
$this->catalog->createModel('/', 'Root fixture');
$this->pdo->beginTransaction();
$winner = $this->catalog->$method('/contested', 'Shared title');
foreach (['createModel', 'ensureModel'] as $contenderMethod) {
$this->timeout(fn () => $this->contender->$contenderMethod('/contested', 'Shared title'));
// A disjoint folder/title cannot contend on the winner's new folder or model rows.
$this->timeout(fn () => $this->contender->$contenderMethod('/unrelated', 'Other title'));
self::assertFalse($this->other->inTransaction());
self::assertSame(1, (int) $this->other->query('SELECT COUNT(*) FROM models')->fetchColumn());
self::assertSame(1, (int) $this->other->query('SELECT COUNT(*) FROM folders')->fetchColumn());
}
$this->pdo->commit();
self::assertSame($winner, $this->contender->ensureModel('/contested', 'Shared title'));
$duplicate = $this->contender->createModel('/contested', 'Shared title');
self::assertNotSame($winner, $duplicate);
try {
$this->contender->ensureModel('/contested', 'Shared title');
self::fail('Legacy lookup must reject two explicit identities.');
} catch (DomainException $error) {
self::assertSame('Ambiguous model title in home folder.', $error->getMessage());
}
self::assertFalse($this->other->inTransaction());
self::assertSame(3, (int) $this->other->query('SELECT COUNT(*) FROM models')->fetchColumn());
}
public static function creationMethods(): array
{
return ['explicit winner' => ['createModel'], 'legacy winner' => ['ensureModel']];
}
public static function transactionOutcomes(): array
{
return ['commit' => [true], 'rollback' => [false]];
}
}
+44 -3
View File
@@ -16,6 +16,45 @@ final class RepositoryDatabase
/** @var array<string, array{schema: string, connection: PDO}> */
private static array $schemas = [];
/** @var array<string, Config> */
private static array $configs = [];
public static function migrateTo(string $path, int $target): void
{
self::runTarget($path, $target, false);
}
public static function rollbackTo(string $path, int $target): void
{
self::runTarget($path, $target, true);
}
private static function runTarget(string $path, int $target, bool $down): void
{
$config = self::$configs[$path] ?? throw new \LogicException('Migrate this isolated path first.');
$manager = new Manager($config, new ArrayInput([]), new NullOutput());
$pdo = $config->getEnvironment('test')['connection'];
if ($pdo->inTransaction()) {
throw new \LogicException('Migration helpers require no active caller transaction.');
}
try {
if ($down) {
$manager->rollback('test', $target);
} else {
$manager->migrate('test', $target);
}
} catch (\Throwable $error) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $error;
} finally {
if (isset(self::$schemas[$path])) {
$pdo->exec('SET search_path TO "' . self::$schemas[$path]['schema'] . '"');
}
}
}
public static function postgresConfigured(): bool
{
$dsn = getenv('MESHVAULT_TEST_PG_DSN');
@@ -23,9 +62,9 @@ final class RepositoryDatabase
}
/** Never a hand-maintained test schema: drift must fail the suite. */
public static function migrate(string $path): PDO
public static function migrate(string $path, ?int $target = null): PDO
{
if (isset(self::$schemas[$path])) {
if (isset(self::$configs[$path]) || isset(self::$schemas[$path])) {
throw new \LogicException('Clean up the previous database before migrating this path again.');
}
$environment = ['adapter' => 'sqlite'];
@@ -48,7 +87,8 @@ final class RepositoryDatabase
'test' => $environment + ['connection' => $pdo],
],
]);
(new Manager($config, new ArrayInput([]), new NullOutput()))->migrate('test');
self::$configs[$path] = $config;
(new Manager($config, new ArrayInput([]), new NullOutput()))->migrate('test', $target);
if (isset(self::$schemas[$path])) {
// Phinx may add public to search_path; repository queries must remain isolated.
$pdo->exec('SET search_path TO "' . self::$schemas[$path]['schema'] . '"');
@@ -78,6 +118,7 @@ final class RepositoryDatabase
public static function cleanup(string $path): void
{
unset(self::$configs[$path]);
if (isset(self::$schemas[$path])) {
['schema' => $schema, 'connection' => $pdo] = self::$schemas[$path];
if ($pdo->inTransaction()) {
+55
View File
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Unit\Catalog;
use InvalidArgumentException;
use MeshVault\Catalog\ManagedPath;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
final class ManagedPathTest extends TestCase
{
#[DataProvider('validPaths')]
public function testPreservesCanonicalSpelling(string $path): void
{
self::assertSame($path, ManagedPath::validate($path));
}
public static function validPaths(): array
{
return [['part.stl'], ['Folder/模型 A.STL'], ['a/.hidden'], ['a/space inside.stl']];
}
#[DataProvider('invalidPaths')]
public function testRejectsAliases(string $path): void
{
$this->expectException(InvalidArgumentException::class);
ManagedPath::validate($path);
}
public static function invalidPaths(): array
{
return array_map(static fn (string $path): array => [$path], [
'', '/a', '//server/a', 'C:/a', 'C:a', 'a\\b', "a\0b", 'https://host/a',
'file:a', 'a//b', 'a/', '.', '..', './a', 'a/../b', 'a/./b',
]);
}
public function testPlatformSpecificRestrictions(): void
{
foreach (['a/b.', 'a/b ', 'a/b:c'] as $path) {
if (PHP_OS_FAMILY === 'Windows') {
try {
ManagedPath::validate($path);
self::fail('Expected Windows alias rejection');
} catch (InvalidArgumentException) {
self::assertTrue(true);
}
} else {
self::assertSame($path, ManagedPath::validate($path));
}
}
}
}