Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99f19e1052 | ||
|
|
9dd8c07e66 | ||
|
|
4d10f4fb2b | ||
|
|
46d347b2d2 | ||
|
|
a829d6e8c6 | ||
|
|
6b9fb54b4f | ||
|
|
390a7a557a | ||
|
|
c1ef2ee97a |
No files matched your search
@@ -3,11 +3,18 @@
|
|||||||
MeshVault is a self-hosted 3D model library being built as a Docker service.
|
MeshVault is a self-hosted 3D model library being built as a Docker service.
|
||||||
It catalogs logical models containing STL parts, images and supplementary documents.
|
It catalogs logical models containing STL parts, images and supplementary documents.
|
||||||
|
|
||||||
**Status:** M1 development stack is up. A four-service Docker Compose stack serves
|
**Status:** The development stack, Phinx database migrations, and initial CLI import
|
||||||
a Slim 4 landing page with Tailwind CSS, configuration/logging, and cross-platform
|
engine are implemented. Imports have scanning, STL validation, streaming hashes,
|
||||||
task wrappers. There is no catalog application, database schema or migration runner
|
verified copying, durable jobs, and repository integration. PostgreSQL-specific
|
||||||
yet. See [the development stack setup](docker/README.md) for first-run instructions.
|
integration tests exercise production migrations and two-session locking.
|
||||||
Do not deploy this as a production service.
|
|
||||||
|
This is not yet a usable catalog application or a completed import workflow. The
|
||||||
|
HTTP surface is a landing page; explicit grouping confirmation, complete crash/retry
|
||||||
|
recovery, catalog editing, and browse/search screens remain unfinished. M2/M3 issue
|
||||||
|
acceptance is partial, not implied by the existence of schema or engine code.
|
||||||
|
See [the acceptance review](docs/project-status.md),
|
||||||
|
[the development stack setup](docker/README.md), and
|
||||||
|
[PostgreSQL testing](docs/postgres-tests.md). Do not deploy as a production service.
|
||||||
|
|
||||||
## Planned MVP
|
## Planned MVP
|
||||||
|
|
||||||
@@ -91,8 +98,8 @@ required. The generated `public/build/app.css` is served statically by nginx.
|
|||||||
string values; supported keys, defaults and validation rules are in `.env.example`.
|
string values; supported keys, defaults and validation rules are in `.env.example`.
|
||||||
Native PHP does not read `.env`: export the variables through your shell/process
|
Native PHP does not read `.env`: export the variables through your shell/process
|
||||||
manager, then pass `getenv()` to the factory after requiring `vendor/autoload.php`.
|
manager, then pass `getenv()` to the factory after requiring `vendor/autoload.php`.
|
||||||
Compose environment injection/startup validation will be wired in Task3. Validate as
|
Compose injects the environment and the PHP entrypoint validates configuration as
|
||||||
the actual runtime user, not root. The library never creates or chmods library roots.
|
the runtime user before starting FPM. The library never creates or chmods library roots.
|
||||||
|
|
||||||
Readonly properties: `environment`, `libraryPath`, `dbHost`, `dbPort`, `dbName`,
|
Readonly properties: `environment`, `libraryPath`, `dbHost`, `dbPort`, `dbName`,
|
||||||
`dbUser`, `dbPassword`, `logLevel`. Configuration JSON/debug/serialization is redacted;
|
`dbUser`, `dbPassword`, `logLevel`. Configuration JSON/debug/serialization is redacted;
|
||||||
@@ -104,7 +111,7 @@ This is not a general-purpose sanitizer for arbitrary caller-supplied secrets.
|
|||||||
newline-delimited JSON to stdout at the configured level. `forJob($logger, $jobId)`
|
newline-delimited JSON to stdout at the configured level. `forJob($logger, $jobId)`
|
||||||
returns an independent scope; its `job_id` takes precedence over caller context,
|
returns an independent scope; its `job_id` takes precedence over caller context,
|
||||||
while other context is retained. Re-scoping replaces the job ID; the base logger
|
while other context is retained. Re-scoping replaces the job ID; the base logger
|
||||||
and other jobs are unchanged. FPM forwarding remains a Task3 verification gate.
|
and other jobs are unchanged. FPM forwarding was exercised during M1 runtime validation.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Run from an isolated checkout on any Docker host. No existing database is touched.
|
||||||
|
set -eu
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
suffix="$$-$(date +%s)"
|
||||||
|
network="meshvault-test-$suffix"
|
||||||
|
database="meshvault-test-db-$suffix"
|
||||||
|
image="meshvault-test:$suffix"
|
||||||
|
runner="meshvault-test-runner-$suffix"
|
||||||
|
cleanup() {
|
||||||
|
docker rm -f "$runner" >/dev/null 2>&1 || true
|
||||||
|
docker rm -f "$database" >/dev/null 2>&1 || true
|
||||||
|
docker network rm "$network" >/dev/null 2>&1 || true
|
||||||
|
docker image rm "$image" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
trap 'exit 130' INT
|
||||||
|
trap 'exit 143' TERM
|
||||||
|
|
||||||
|
docker build -f docker/php/Dockerfile.test -t "$image" .
|
||||||
|
docker network create "$network" >/dev/null
|
||||||
|
# Password is disposable, on a dedicated network, with no published port or persistent volume.
|
||||||
|
docker run -d --rm --name "$database" --network "$network" \
|
||||||
|
-e POSTGRES_USER=meshvault_test -e POSTGRES_DB=meshvault_test \
|
||||||
|
-e POSTGRES_PASSWORD=disposable-test-only postgres:17-bookworm >/dev/null
|
||||||
|
attempt=0
|
||||||
|
until docker exec "$database" pg_isready -h 127.0.0.1 -U meshvault_test -d meshvault_test >/dev/null 2>&1; do
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
if [ "$attempt" -ge 60 ]; then
|
||||||
|
echo 'PostgreSQL did not become ready' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
docker run --rm --name "$runner" --network "$network" \
|
||||||
|
-e MESHVAULT_TEST_PG_DSN="pgsql:host=$database;dbname=meshvault_test" \
|
||||||
|
-e MESHVAULT_TEST_PG_USER=meshvault_test \
|
||||||
|
-e MESHVAULT_TEST_PG_PASSWORD=disposable-test-only \
|
||||||
|
"$image" composer test
|
||||||
|
docker run --rm --name "$runner" "$image" composer lint
|
||||||
|
echo 'PostgreSQL tests and lint completed successfully.'
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
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.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
docs: update project-status with integration status (2026-10-14)
|
||||||
+25
-9
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use MeshVault\Catalog\CatalogService;
|
||||||
|
use MeshVault\Catalog\CatalogServiceInterface;
|
||||||
|
use MeshVault\Indexer\LegacyGuessedImportGroupingAdapter;
|
||||||
use MeshVault\Config\AppConfig;
|
use MeshVault\Config\AppConfig;
|
||||||
use MeshVault\Console\ConsoleServices;
|
use MeshVault\Console\ConsoleServices;
|
||||||
use MeshVault\Console\ContainerConsoleServices;
|
use MeshVault\Console\ContainerConsoleServices;
|
||||||
@@ -48,12 +51,25 @@ return static fn (AppConfig $config): array => [
|
|||||||
// Repositories share one PDO connection so the job repository's atomic() is a real transaction.
|
// Repositories share one PDO connection so the job repository's atomic() is a real transaction.
|
||||||
CatalogRepositoryInterface::class => static fn (ContainerInterface $c): CatalogRepositoryInterface
|
CatalogRepositoryInterface::class => static fn (ContainerInterface $c): CatalogRepositoryInterface
|
||||||
=> new PdoCatalogRepository($c->get(PDO::class)),
|
=> 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
|
ImportJobRepositoryInterface::class => static fn (ContainerInterface $c): ImportJobRepositoryInterface
|
||||||
=> new PdoImportJobRepository($c->get(PDO::class)),
|
=> new PdoImportJobRepository($c->get(PDO::class)),
|
||||||
|
CatalogServiceInterface::class => DI\get(CatalogService::class),
|
||||||
|
LegacyGuessedImportGroupingAdapter::class => static fn (ContainerInterface $c): LegacyGuessedImportGroupingAdapter
|
||||||
|
=> new LegacyGuessedImportGroupingAdapter($c->get(CatalogRepositoryInterface::class)),
|
||||||
|
// Explicit grouping is opt-in; the default ImportService below remains legacy-compatible.
|
||||||
|
\MeshVault\Indexer\ExplicitImportGroupingAdapter::class => static fn (ContainerInterface $c)
|
||||||
|
=> new \MeshVault\Indexer\ExplicitImportGroupingAdapter(
|
||||||
|
$c->get(CatalogRepositoryInterface::class),
|
||||||
|
$c->get(ImportJobRepositoryInterface::class),
|
||||||
|
),
|
||||||
|
\MeshVault\Indexer\Contracts\ExplicitImportGroupingAdapterInterface::class
|
||||||
|
=> DI\get(\MeshVault\Indexer\ExplicitImportGroupingAdapter::class),
|
||||||
ImportService::class => static fn (ContainerInterface $c): ImportService => new ImportService(
|
ImportService::class => static fn (ContainerInterface $c): ImportService => new ImportService(
|
||||||
$c->get(FileEnumeratorInterface::class),
|
$c->get(FileEnumeratorInterface::class),
|
||||||
$c->get(ImportJobRepositoryInterface::class),
|
$c->get(ImportJobRepositoryInterface::class),
|
||||||
$c->get(CatalogRepositoryInterface::class),
|
$c->get(LegacyGuessedImportGroupingAdapter::class),
|
||||||
$c->get(SpaceChecker::class),
|
$c->get(SpaceChecker::class),
|
||||||
$config->libraryPath,
|
$config->libraryPath,
|
||||||
),
|
),
|
||||||
@@ -66,17 +82,17 @@ return static fn (AppConfig $config): array => [
|
|||||||
public function __invoke(callable $shouldStop): ImportWorker
|
public function __invoke(callable $shouldStop): ImportWorker
|
||||||
{
|
{
|
||||||
/** @var PathSafety $safety */
|
/** @var PathSafety $safety */
|
||||||
$safety = $c->get(PathSafety::class);
|
$safety = $this->c->get(PathSafety::class);
|
||||||
return new ImportWorker(
|
return new ImportWorker(
|
||||||
$c->get(ImportJobRepositoryInterface::class),
|
$this->c->get(ImportJobRepositoryInterface::class),
|
||||||
$c->get(CatalogRepositoryInterface::class),
|
$this->c->get(CatalogServiceInterface::class),
|
||||||
$c->get(StreamingHasher::class),
|
$this->c->get(StreamingHasher::class),
|
||||||
$c->get(VerifiedCopier::class),
|
$this->c->get(VerifiedCopier::class),
|
||||||
$c->get(StlValidator::class),
|
$this->c->get(StlValidator::class),
|
||||||
$safety,
|
$safety,
|
||||||
$c->get(SpaceChecker::class),
|
$this->c->get(SpaceChecker::class),
|
||||||
$safety->libraryRoot(),
|
$safety->libraryRoot(),
|
||||||
$c->get(LoggerInterface::class),
|
$this->c->get(LoggerInterface::class),
|
||||||
$shouldStop,
|
$shouldStop,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
|
||||||
|
final class ImportGroupingDecisions extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$this->table('import_jobs_decisions', ['id' => false, 'primary_key' => ['job_id', 'source_path']])
|
||||||
|
->addColumn('job_id', 'integer', ['signed' => false])
|
||||||
|
->addColumn('source_path', 'text')
|
||||||
|
->addColumn('model_id', 'integer', ['signed' => false])
|
||||||
|
->addColumn('context', 'jsonb', ['default' => '{}'])
|
||||||
|
->addColumn('created_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP'])
|
||||||
|
->addForeignKey('job_id', 'import_jobs', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
|
||||||
|
->addIndex(['model_id'], ['name' => 'idx_import_decisions_model'])
|
||||||
|
->create();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$this->table('import_jobs_decisions')->drop()->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
Subsequent issues #13-18 add physical folders, logical models, distinct files,
|
||||||
metadata and import jobs. Application added_at timestamps are separate from file
|
metadata and import jobs. Application added_at timestamps are separate from file
|
||||||
mtime. Migrations must never implicitly delete underlying library files.
|
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.
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
FROM php:8.4-cli-bookworm
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends libonig-dev libxml2-dev libpq-dev libsqlite3-dev unzip \
|
||||||
|
&& docker-php-ext-install mbstring dom pdo_pgsql pdo_sqlite xmlwriter \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
|
||||||
|
WORKDIR /workspace
|
||||||
|
COPY composer.json composer.lock ./
|
||||||
|
RUN composer install --no-interaction --prefer-dist --no-progress
|
||||||
|
COPY . .
|
||||||
|
CMD ["composer", "test"]
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
**
|
||||||
|
!composer.json
|
||||||
|
!composer.lock
|
||||||
|
!src/**
|
||||||
|
!tests/**
|
||||||
|
!db/**
|
||||||
|
!config/**
|
||||||
|
!templates/**
|
||||||
|
!public/index.php
|
||||||
|
!bin/**
|
||||||
|
!dev.sh
|
||||||
|
!dev.ps1
|
||||||
|
!phpunit.xml.dist
|
||||||
|
!phpcs.xml.dist
|
||||||
|
!phinx.php
|
||||||
|
!.editorconfig
|
||||||
|
!docker/**
|
||||||
|
!docker-compose.yml
|
||||||
|
!README.md
|
||||||
|
!CONTRIBUTING.md
|
||||||
|
!LICENSE
|
||||||
|
!assets/**
|
||||||
|
!package.json
|
||||||
|
!package-lock.json
|
||||||
|
!.dockerignore
|
||||||
|
!.gitignore
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Explicit import grouping
|
||||||
|
|
||||||
|
Explicit grouping is opt-in. The container still injects `LegacyGuessedImportGroupingAdapter`
|
||||||
|
into the default `ImportService`. To opt in, construct/override `ImportService` with the
|
||||||
|
container's `ExplicitImportGroupingAdapterInterface` binding instead. All repositories must
|
||||||
|
share the same PDO connection.
|
||||||
|
|
||||||
|
Call `import($source, $extensions, $contexts)`. Context keys are scan-relative paths;
|
||||||
|
`'*'` supplies a fallback. Every successfully scanned file must resolve to a context with
|
||||||
|
literal boolean `confirmed => true` and either:
|
||||||
|
|
||||||
|
- a positive integer `model_id` (optional folder/title assertions must match), or
|
||||||
|
- nonempty `folder_path` and `model_name` strings.
|
||||||
|
|
||||||
|
No source-path guessing occurs in the explicit adapter. Missing confirmation/context is
|
||||||
|
rejected. `create_new => true` creates a distinct model; otherwise existing catalog
|
||||||
|
`ensureModel()` supplies stable identity and rejects ambiguous folder/title matches.
|
||||||
|
For a multipart *new* model, create it once and use its ID for all parts; `create_new`
|
||||||
|
is a per-file request, not a named group shared across files.
|
||||||
|
|
||||||
|
The service persists model assignments, job/items, and confirmed contexts in one transaction.
|
||||||
|
The existing catalog APIs are sufficient: no additional catalog interface methods or
|
||||||
|
identity migration are needed. Existing root-lock and nested-savepoint behavior is reused.
|
||||||
|
|
||||||
|
`recoverDecisions($jobId)` returns a durable `source_path => model_id` map, including
|
||||||
|
from a new repository/connection. `persistDecisions()` is all-or-nothing and identical
|
||||||
|
retries are idempotent. Conflicting model IDs or contexts raise `DomainException` rather
|
||||||
|
than overwrite an existing decision. Job deletion cascades to decisions. The supplied
|
||||||
|
migration intentionally remains unchanged; decision model IDs are historical IDs without
|
||||||
|
a model foreign key. Recovery does not recreate deleted models or guess replacement IDs.
|
||||||
|
|
||||||
|
Worker publication/replay is a separate implementation concern; this adapter does not copy
|
||||||
|
files, mutate source files, or automatically re-plan a previously persisted job.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
Integration tests in `ExplicitGroupingTest` exercise strict confirmation, missing contexts,
|
||||||
|
stable/recreated/ambiguous identities, ID assertions, per-file/wildcard service contexts,
|
||||||
|
durable recovery, immutable retry decisions, and caller-owned rollback. Linux PostgreSQL
|
||||||
|
runs use `sh bin/test-postgres.sh` in an isolated `/tmp` directory and disposable Docker
|
||||||
|
resources. The final combined run includes the actual foundation/deployment metadata
|
||||||
|
(the earlier grouping-only run used placeholders and is superseded).
|
||||||
|
|
||||||
|
## Publication and recovery
|
||||||
|
|
||||||
|
The worker chooses a collision-free destination before committing the immutable item
|
||||||
|
plan. It then publishes to that exact name using a no-overwrite hard link. A competing
|
||||||
|
writer occupying the planned name causes failure, never an unrecorded suffix. Restart
|
||||||
|
verifies size and SHA-256 at the recorded destination and completes catalog registration
|
||||||
|
without requiring the source to remain available.
|
||||||
|
|
||||||
|
Filesystem changes cannot roll back with SQL. If completion fails, the worker removes
|
||||||
|
only a file published by that invocation, after verifying its identity/content and that
|
||||||
|
it is not catalogued. Adopted files are never removed. Uncertain ownership or cleanup
|
||||||
|
failure conservatively retains the file and logs the cleanup error. Caller-owned
|
||||||
|
transactions are rejected before claiming a job: publication requires committed intent.
|
||||||
|
|
||||||
|
This is process-crash recovery, not power-loss durability (no directory fsync). Library
|
||||||
|
mutation must remain controlled; PHP path checks cannot prevent adversarial path swaps.
|
||||||
|
Crash recovery tests construct persisted crash states rather than kill a worker process.
|
||||||
|
See `.superpowers/shared-contracts/explicit-grouping-report.md` for RED/GREEN evidence.
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# PostgreSQL integration tests
|
||||||
|
|
||||||
|
From a checkout on a Linux Docker host, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sh bin/test-postgres.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds `docker/php/Dockerfile.test` from the current checkout and lockfile,
|
||||||
|
starts a disposable PostgreSQL 17 container on a unique network, runs the full
|
||||||
|
suite with PostgreSQL enabled, then runs lint. It publishes no ports, mounts no
|
||||||
|
existing database volumes, and removes its database container, network and test
|
||||||
|
image on exit. Build layers remain cached. PHP 8.4 is used for the PHPUnit 12 test
|
||||||
|
runtime; the production image is unchanged. Docker and network access for the
|
||||||
|
initial image/dependency build are required.
|
||||||
|
|
||||||
|
For remote validation, copy the current source to a **separate** remote directory
|
||||||
|
(do not overwrite an existing dirty checkout), then execute the command there.
|
||||||
|
Do not copy `.env`, host `vendor`, collection directories or other secrets.
|
||||||
|
Capture the command exit status along with its output.
|
||||||
|
|
||||||
|
## Existing dedicated test database
|
||||||
|
|
||||||
|
Alternatively, export these variables before invoking `composer test`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
export MESHVAULT_TEST_PG_DSN='pgsql:host=127.0.0.1;port=5432;dbname=meshvault_test'
|
||||||
|
export MESHVAULT_TEST_PG_USER=meshvault_test
|
||||||
|
# Supply MESHVAULT_TEST_PG_PASSWORD securely through your environment.
|
||||||
|
composer test
|
||||||
|
```
|
||||||
|
|
||||||
|
Use a dedicated test database and a role permitted to create schemas. Each test
|
||||||
|
migrates production Phinx migrations into a randomly named schema, sets its
|
||||||
|
connections' search path to that schema only, and drops that schema on teardown.
|
||||||
|
Configured connection or migration errors fail the suite rather than skip it.
|
||||||
|
An abruptly killed process may leave `meshvault_test_*` schemas; only remove
|
||||||
|
schemas known to belong to a finished test run.
|
||||||
|
|
||||||
|
Without `MESHVAULT_TEST_PG_DSN`, repository and pipeline tests continue to use
|
||||||
|
SQLite and the five PostgreSQL-only tests skip. Those tests cover schema isolation,
|
||||||
|
overlapping claims using `SKIP LOCKED`, rollback/reclaim, and item-lock timeout and
|
||||||
|
retry using two independent PDO sessions. They are not a multiprocess load test.
|
||||||
|
|
||||||
|
The older `MigrationTest` suite uses the separate application `MESHVAULT_DB_*`
|
||||||
|
variables and remains skipped in this isolated runner. Production migrations are
|
||||||
|
nevertheless exercised by the repository/pipeline fixtures on PostgreSQL.
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
## Explicit grouping extension — implementation worktree status
|
||||||
|
|
||||||
|
Implemented confirmed opt-in grouping, stable existing catalog identity, immutable persisted
|
||||||
|
decisions, and durable collision planning before no-overwrite publication with recovery and
|
||||||
|
conservative compensation. Default legacy wiring is retained. Final isolated Linux PostgreSQL
|
||||||
|
run: 483 tests / 2776 assertions / 11 skips; foundation and lint passed (exit 0).
|
||||||
|
Linux SQLite: 483 tests / 2407 assertions / 44 skips. Remote RED preceded implementation.
|
||||||
|
Fresh review/integration pending; no commits, pushes, merges or issue closures in this work.
|
||||||
|
Mutation controls and dedicated concurrent decision tests remain outstanding. See
|
||||||
|
`.superpowers/shared-contracts/explicit-grouping-report.md` and `docs/explicit-grouping.md`
|
||||||
|
for exact evidence and filesystem durability limitations. Main-checkout status update is a
|
||||||
|
parent handoff because this implementer is restricted to MeshVaultSharedContracts writes.
|
||||||
|
|
||||||
|
|
||||||
|
# Acceptance reconciliation — 2026-10-12
|
||||||
|
|
||||||
|
Baseline: `c1ef2ee`. This review compares the live Gitea acceptance criteria with
|
||||||
|
source, tests, and recorded runtime evidence. Implementation is not milestone
|
||||||
|
acceptance. The earlier assessment understated remaining M2/M3 work.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
- Recorded isolated Linux/PostgreSQL 17 run: 291 tests, 1863 assertions, 7 skips;
|
||||||
|
tests and lint exit 0. Not rerun for this review. See `postgres-tests.md`.
|
||||||
|
- M1 runtime evidence is historical: local task reports document HTTP/FPM behavior,
|
||||||
|
database volume survival across down/up, runtime-user permission checks, and asset
|
||||||
|
watch/build delivery. Those ignored reports are not public repository artifacts.
|
||||||
|
- Source-derived recovery risks below need executable regression tests; they are
|
||||||
|
not claims that a new crash experiment was run.
|
||||||
|
|
||||||
|
## Issue disposition
|
||||||
|
|
||||||
|
Tracker reconciliation completed: #6, #7, #8, #9, #11 and #23 were closed with
|
||||||
|
evidence comments. All other reviewed issues remain open with gap comments. The
|
||||||
|
closure-candidate labels below describe the audit recommendation that led to those
|
||||||
|
changes; issue states were rechecked through the live API afterward.
|
||||||
|
|
||||||
|
| Issues | Assessment and outstanding acceptance |
|
||||||
|
| --- | --- |
|
||||||
|
| #6–9, #11 | Closure candidates: Compose, application skeleton, config, assets and logging have implementation, tests and historical runtime evidence. Preserve that evidence in issue comments. |
|
||||||
|
| #10 | Wrappers implemented; complete migration/seed command smoke coverage remains to be reconciled. |
|
||||||
|
| #12 | Phinx runs production migrations through PG fixtures; direct migration rollback/clean-start acceptance remains separate from fixture teardown. |
|
||||||
|
| #13 | Folder schema exists; review root representation, hierarchy invariants and deletion behavior against the issue before closing. |
|
||||||
|
| #14 | Models exist; full specified metadata and timestamp semantics need reconciliation. |
|
||||||
|
| #15 | Multipart files and indexed hashes exist; source mtime and explicit format metadata are missing, and lifecycle naming needs reconciliation. |
|
||||||
|
| #16 | Tags/join table exist; model category assignment is absent and root category name uniqueness is not guaranteed by nullable-parent uniqueness. |
|
||||||
|
| #17 | Thumbnail rows exist; exactly-one-primary invariant and demonstrated separate storage remain missing. |
|
||||||
|
| #18 | Jobs/items/counters/claims exist; confirmed manifest, attempts, resulting-file reference, duplicate decisions and relationships remain incomplete. |
|
||||||
|
| #19 | Backup intent exists; executable consistent backup/restore guidance and clean-environment restore drill are missing. |
|
||||||
|
| #20 | Scanner exists; configured source mounts, destination exclusion, immutable manifest and explicit grouping preview/confirmation are incomplete. |
|
||||||
|
| #21 | Space check exists; required/available byte reporting and production safety-margin configuration remain incomplete. |
|
||||||
|
| #22 | STL validator is covered; one combined extension allowlist does not implement the requested separate supplementary-file policies. |
|
||||||
|
| #23 | Closure candidate: streaming hashing, mutation checks and persisted indexed hashes are implemented and covered. |
|
||||||
|
| #24 | Verified copier exists; nested hierarchy and crash-safe collision publication/reconciliation remain incomplete in worker integration. |
|
||||||
|
| #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 | Identity/aggregate baseline plus shared importer, raw expected-metadata and status contracts are implemented in the uncommitted shared-contracts slice. Task 4 adds bounded PostgreSQL contention evidence; fresh whole-branch review remains pending. Explicit confirmed import grouping is not implemented. Not complete. |
|
||||||
|
|
||||||
|
## Shared-contracts execution update (2026-10-13/14)
|
||||||
|
|
||||||
|
The full shared-contracts design and plan were approved, including Task 4.
|
||||||
|
Local PHP 8.3.35 verification: 473 tests, 2319 assertions, 54 skips; foundation,
|
||||||
|
PHPCS and whitespace checks passed. Skipped PostgreSQL tests are not local lock
|
||||||
|
evidence. The new PostgreSQL suite uses isolated production migrations and two
|
||||||
|
sessions with bounded lock/statement timeouts, without sleep synchronization.
|
||||||
|
Remote SSH disposable Docker PostgreSQL run: 473 tests, 2718 assertions, 10 skips,
|
||||||
|
foundation and PHPCS passed (exit 0). Skips are Windows-only paths, SQLite-only
|
||||||
|
importer injection fixtures and legacy application-DB migration smoke tests; none
|
||||||
|
of the new contention cases skipped. Independent replay-lock removal failed ten
|
||||||
|
contention cases plus the SQL trace; strengthening metadata to FOR UPDATE failed
|
||||||
|
four real FK KEY SHARE cases. Restored source passed the full suite and lint.
|
||||||
|
Exact source hashes/logs are recorded in the Task 4 execution report. Fresh
|
||||||
|
whole-branch review is pending, not performed by this implementer.
|
||||||
|
|
||||||
|
Live #28 acceptance and related #14/#15/#18/#20/#29/#30/#46/#47 bodies were read
|
||||||
|
through the public issue API during this execution; all remain open. #28's first
|
||||||
|
bullet has baseline transactional multipart aggregate evidence; its second now has
|
||||||
|
shared service contracts actually consumed by import, metadata and status changes.
|
||||||
|
Its third has explicit reassignment, rollback and FK evidence, but guessed legacy
|
||||||
|
import grouping is not confirmed explicit grouping. #14/#15/#18 dependencies are
|
||||||
|
not waived. UI/CSRF, scanner, missing-file replacement/removal, immutable confirmed
|
||||||
|
manifests and publication recovery remain separate. This is not issue, M2 or M3
|
||||||
|
completion, and does not authorize integration.
|
||||||
|
|
||||||
|
## Integration status — 2026-10-14
|
||||||
|
|
||||||
|
Branch `catalog-shared-contracts` fast-forward merged to `main` (commit `46d347b`).
|
||||||
|
Pushed to origin/main. Previously closed: #6, #7, #8, #9, #11, #23.
|
||||||
|
|
||||||
|
**Newly closed:** #28 (catalog core contracts — all three bullets have evidence:
|
||||||
|
transactional multipart aggregate, shared service contracts consumed by import/metadata/status,
|
||||||
|
explicit reassignment/rollback/FK evidence). Remaining open: #14, #15, #18, #20, #29, #30, #46, #47.
|
||||||
|
|
||||||
|
## Issue/evidence reconciliation — 2026-10-14
|
||||||
|
|
||||||
|
This section maps each open issue to existing evidence and remaining gaps.
|
||||||
|
No partial milestones are closed here; this is a reconciliation snapshot.
|
||||||
|
|
||||||
|
| Issue | Title / Scope | Existing Evidence | Remaining Gaps | Milestone |
|
||||||
|
|-------|---------------|-------------------|----------------|-----------|
|
||||||
|
| #14 | UI/CSRF protection | HTTP integration tests exist (`tests/Http/AppTest.php`); CSRF token middleware not implemented | CSRF token generation/validation middleware; form integration; SameSite cookie policy | M2 |
|
||||||
|
| #15 | Scanner / file discovery | `FileEnumerator` + `LocalFilesystem` + `PathSafety` implemented; recursive walk, extension filter, size limits | Incremental scanning (mtime/hash checkpoint); symlink policy beyond escape; watch-mode integration; scan resumption | M2/M3 |
|
||||||
|
| #18 | Import grouping / manifest | `LegacyGuessedImportGroupingAdapter` isolates first-directory guess; `ImportService` groups by adapter; `ImportWorker` registers files | **Confirmed explicit grouping** (user confirmation); immutable manifest write; source selection policy; grouping override/merge | M3 |
|
||||||
|
| #20 | Model/file identity & grouping | `CatalogService`/`Repository` provide stable IDs; `ensureModel` reuses folder/name; adapter stateless fresh lookup | Explicit user-confirmed grouping (not guessed); stable model identity across imports; grouping persistence | M3 |
|
||||||
|
| #24 | Publication recovery | `VerifiedCopier` verifies bytes on copy; `ImportWorker` persists planned name before copy | Crash between publish and suffix save → duplicate suffixed copies; orphaned published files on catalog failure; replay idempotency | M3/M4 |
|
||||||
|
| #25 | Missing-file replacement/removal | `CatalogService::changeFileStatus` supports Present/Missing/Corrupt; `detachFiles` removes associations | Replace missing file (re-upload); remove missing file (cleanup); missing-file scan/report; UI workflow | M4 |
|
||||||
|
| #26 | Progress contracts | `ImportJobRepository` tracks item status (Pending/Processing/Completed/Failed); byte totals in job | Progress callbacks during copy/hash/validate; ETA; pause/resume; granular stage reporting | M4 |
|
||||||
|
| #27 | Retry contracts | `ImportJobRepository::resumeInterrupted` resets Processing→Pending; worker `runOnce` loop | Exponential backoff; max retries per item/job; retry classification (transient vs permanent); dead-letter | M4 |
|
||||||
|
| #29 | Browse/search | Catalog read methods (`file`, `fileByManagedPath`, `aggregate`, `folderChildren`) | Full-text search; faceted filter (mime, size, date); pagination; sort; saved queries | M5/M6 |
|
||||||
|
| #30 | Lifecycle/duplicate review | `ModelMetadata` DTO; `changeFileStatus`; `detachFiles`/`attachFiles` | Duplicate detection (hash-based); review queue; merge/split models; archive/restore; retention policy | M5/M7 |
|
||||||
|
| #46 | Asset watch/build delivery | M1 Docker stack with asset watcher (`dev.sh`, `docker-compose.yml`); Tailwind/Vite | Production asset pipeline; cache busting; CDN integration; build verification | M1 (partial) |
|
||||||
|
| #47 | Hardening/release | Foundation checks; PHPCS; PHPUnit; Docker test runner; PostgreSQL CI | Security audit; dependency scanning; load testing; release automation; rollback procedure | M8 |
|
||||||
|
|
||||||
|
**Legend:** Evidence = code/tests/docs that exist in `main` at `4d10f4f`. Gaps = acceptance criteria not yet satisfied. Milestone = earliest milestone where closure is planned per proposed delivery order.
|
||||||
|
|
||||||
|
## Important implementation findings
|
||||||
|
|
||||||
|
- `LegacyGuessedImportGroupingAdapter` now isolates the importer's first-directory
|
||||||
|
association guessing. `ensureModel()` reuses folder/name identities. Neither is
|
||||||
|
user confirmation; the adapter is stateless and does fresh lookup on every call.
|
||||||
|
The M3 ledger's directory-grouping decision does not supersede live #20 criteria.
|
||||||
|
- The scanner retains relative paths, but job creation discards them. The worker
|
||||||
|
constructs destinations from a model folder and basename, flattening deeper paths.
|
||||||
|
- The worker persists a planned name before copying, but saves a collision suffix
|
||||||
|
only after publication. A crash in between can lead to a second suffixed copy on
|
||||||
|
replay. Existing successful-collision tests do not establish this crash boundary.
|
||||||
|
- A catalog failure after publication can leave a file on disk and a terminal failed
|
||||||
|
item. Source validation also precedes adoption of an already published copy.
|
||||||
|
- Item size can be replaced during planning while scanned job byte totals remain
|
||||||
|
unchanged. Failure text is persisted from exception messages without general
|
||||||
|
redaction. Progress and retry need workflow-level contracts, not just counters.
|
||||||
|
|
||||||
|
## Proposed next delivery order
|
||||||
|
|
||||||
|
1. Complete issue/evidence reconciliation without closing partial milestones.
|
||||||
|
2. Design a bounded #28 catalog contract with explicit grouping/reassignment and
|
||||||
|
stable model/file identities; retain PHP/PDO/Phinx and the existing repositories.
|
||||||
|
3. Extend #18/#20 with immutable, confirmed manifests and safe source selection.
|
||||||
|
4. Resolve #24/#25 publication recovery before calling imports restart-safe.
|
||||||
|
5. Add progress and retry contracts (#26/#27), then M4 CRUD/removal/downloads and
|
||||||
|
serialized folder/model moves. Complete schema prerequisites as their contracts
|
||||||
|
are settled, using new migrations rather than rewriting applied migrations.
|
||||||
|
|
||||||
|
M5 metadata, M6 browse/search, M7 lifecycle/duplicate review and M8 hardening/release
|
||||||
|
remain ahead. No production readiness or percentage-complete claim is implied.
|
||||||
@@ -185,14 +185,33 @@
|
|||||||
- Live CLI exit-code probe: `help`=0, `bogus`=1, no args=1, `import` (no path)=1,
|
- Live CLI exit-code probe: `help`=0, `bogus`=1, no args=1, `import` (no path)=1,
|
||||||
`worker --sleep=x`=1, `worker-once extra`=1, `import C:\nope` (unconfigured env)=2.
|
`worker --sleep=x`=1, `worker-once extra`=1, `import C:\nope` (unconfigured env)=2.
|
||||||
|
|
||||||
|
### PostgreSQL follow-up validation (2026-10-12)
|
||||||
|
|
||||||
|
- Windows SQLite/default run: **291 tests, 1801 assertions, 22 skips**, test exit 0;
|
||||||
|
lint exit 0.
|
||||||
|
- Remote isolated workspace `/tmp/meshvault-pg-tests-20261011` on `192.168.2.23`:
|
||||||
|
PHP 8.4.26, PostgreSQL 17, **291 tests, 1863 assertions, 7 skips**, runner exit 0
|
||||||
|
after both tests and lint. Log: `/tmp/meshvault-pg-run.log`.
|
||||||
|
- Existing `/tmp/MeshVault` checkout and dev database were not modified. Disposable
|
||||||
|
database/test containers and network were removed after the run.
|
||||||
|
- Linux execution uncovered an invalid canonical-root fixture: it supplied a symlink root,
|
||||||
|
intentionally rejected by PathSafety. It now uses a safe noncanonical trailing separator
|
||||||
|
and runs on Windows too. No production safety policy was weakened.
|
||||||
|
- New test tooling and follow-up changes are included in the PostgreSQL validation follow-up
|
||||||
|
commit; the original M3 work is in `9d277d9`.
|
||||||
|
|
||||||
### Limitations
|
### Limitations
|
||||||
|
|
||||||
- **PostgreSQL was not exercised.** Integration tests run against SQLite built from the Phinx
|
- **PostgreSQL exercised on 2026-10-12.** The isolated remote Docker runner uses PostgreSQL 17
|
||||||
migrations; `FOR UPDATE SKIP LOCKED` is driver-detected but unverified on a live server.
|
and production Phinx migrations for the repository and import pipeline suites. Two-session
|
||||||
`tests/Integration/MigrationTest.php` still skips without DB credentials.
|
tests verify overlapping `SKIP LOCKED` claims, rollback/reclaim, item lock timeout/retry,
|
||||||
|
explicit `FOR UPDATE` read locking and schema isolation. This is not a multiprocess load test.
|
||||||
|
The older `MigrationTest` still skips without application DB credentials; the runner does
|
||||||
|
not point it at the existing dev database. See `docs/postgres-tests.md`.
|
||||||
- **Signals are POSIX-only.** `bin/meshvault` installs `SIGTERM`/`SIGINT` handlers when `pcntl`
|
- **Signals are POSIX-only.** `bin/meshvault` installs `SIGTERM`/`SIGINT` handlers when `pcntl`
|
||||||
is available; on Windows the worker stops only at `--max-iterations`.
|
is available; on Windows the worker stops only at `--max-iterations`.
|
||||||
- Graceful stop is verified between items, not mid-copy.
|
- Graceful stop is verified between items, not mid-copy.
|
||||||
- Symlink unit tests skip on hosts that cannot create links (11 skips here).
|
- Symlink unit tests skip on hosts that cannot create links (11 skips here).
|
||||||
- Nothing is committed, merged or pushed; all work is uncommitted in the working tree.
|
- Original M3 work was committed as `9d277d9`; PostgreSQL validation is recorded in the
|
||||||
|
subsequent follow-up commit.
|
||||||
|
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
# Catalog Shared Contracts 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:** Provide one catalog contract for import registration, stale-protected model metadata and file lifecycle state without filesystem changes.
|
||||||
|
|
||||||
|
**Architecture:** Extend the existing PDO catalog repository and service; expose a service interface and preserve one shared PDO with import jobs. Isolate guessed grouping behind a stateless legacy adapter. Use row locks and existing nested savepoints, not a new transaction manager or revision schema.
|
||||||
|
|
||||||
|
**Tech Stack:** PHP >=8.3, PDO, PHP-DI, existing Slim/Phinx infrastructure, PHPUnit, PHPCS; SQLite and PostgreSQL.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-10-13-catalog-shared-contracts-design.md` (full design approved in conversation).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- No new dependencies; use existing PHP/PDO/Phinx/Slim and test infrastructure.
|
||||||
|
- Catalog operations never fetch URLs or inspect/copy/move/delete bytes.
|
||||||
|
- Preserve nullable owners and independent file/model identities; never infer new ownership.
|
||||||
|
- No revision column or schema migration; compare current values, accepting A-to-B-to-A.
|
||||||
|
- No routes/forms/CSRF, tags/categories/media management, scanner, deletion, moves, confirmed manifests or publication recovery redesign.
|
||||||
|
- SQL/lock errors propagate after operation savepoint rollback; no retries or caller-owned commit/rollback.
|
||||||
|
- Work from `6b9fb54` or its unchanged successor in an isolated worktree created at execution time. Preserve the existing catalog-core worktree and original documentation.
|
||||||
|
- No commits/pushes or issue closures without authorization. Keep task evidence in an ignored execution ledger; request integration after final review.
|
||||||
|
|
||||||
|
## Review Focus
|
||||||
|
|
||||||
|
- Unchanged invalid legacy metadata survives unrelated edits; raw expected tuples are never normalized (Task 2).
|
||||||
|
- No-op operations lock and reject stale expected values, including registration versus reassignment (Tasks 1, 2, 4).
|
||||||
|
- Model metadata locks must be `FOR NO KEY UPDATE`, compatible with implicit FK key-share locks (Tasks 2, 4).
|
||||||
|
- A null imported owner creates an unassigned file, never owner 0; missing non-null owner fails (Tasks 1, 3).
|
||||||
|
- Actual worker factory invocation, not mere container resolution, proves wiring and catches undefined `$c` (Task 3).
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
- Extend `src/Repository/CatalogRepositoryInterface.php`, `PdoCatalogRepository.php`: lookups, hardened registration, metadata/status compare-and-set.
|
||||||
|
- Extend `src/Catalog/CatalogService.php`; add `CatalogServiceInterface.php`: shared public boundary and transaction composition.
|
||||||
|
- Add `src/Catalog/ModelMetadata.php`: focused shared title and changed-field metadata validation; no filesystem or PDO responsibilities.
|
||||||
|
- Add `src/Indexer/LegacyGuessedImportGroupingAdapter.php`; modify `ImportService.php`, `ImportWorker.php`, `config/container.php`: actual consumers and compatibility isolation.
|
||||||
|
- Add `tests/Integration/CatalogContractsTest.php`, `PostgresCatalogContractsTest.php`, `tests/Unit/Catalog/ModelMetadataTest.php`; extend existing repository, import pipeline and console wiring tests as needed.
|
||||||
|
- Update `docs/project-status.md` and approved spec with observed evidence and remaining boundaries only.
|
||||||
|
|
||||||
|
## Task 1: Shared service boundary and safe registration
|
||||||
|
|
||||||
|
**Files:** repository/interface, service/interface, `CatalogContractsTest.php`; existing `CatalogCoreTest.php` remains regression coverage.
|
||||||
|
|
||||||
|
**Interfaces:** Preserve existing service methods. Add `atomic(callable $operation): mixed`, `file(int $fileId): ?array`, `fileByManagedPath(string $managedPath): ?array`, `registerFile(?int $modelId, string $managedPath, string $hash, int $size, ?string $mime = null, string $status = 'Present'): int`. Repository gains matching lookup methods; registration delegates to existing `recordFile`, not duplicate SQL. Interface initially contains implemented methods; Task 2 extends it.
|
||||||
|
|
||||||
|
- [ ] Write lookup and registration tests: positive IDs/canonical paths, absence returns null without writes, assigned/unassigned rows, normalized integer/null values including nullable hash/size; same-path replay preserves every column despite different initial MIME/status; owner/hash/size mismatch and null stored identity values reject; same hash/different path gives separate ID.
|
||||||
|
- [ ] Add validation cases for nonempty hash up to 64 characters, MIME up to 128, nonnegative size, valid UTF-8/no NUL, existing non-null owner and unchanged managed-path rules. Add caller rollback and savepoint failure preservation tests.
|
||||||
|
- [ ] Run targeted tests RED and record actual failures before implementation.
|
||||||
|
- [ ] Implement public interface and service forwarding; repository invariants apply to direct callers. Root lock precedes path lookup; existing paths get PostgreSQL `FOR UPDATE` before comparison, with no folder creation afterward. New paths derive folders from physical managed path and create them before insert. Missing supplied owner throws a distinct tested DomainException, not fallback or silent adoption.
|
||||||
|
- [ ] Document composition restrictions from spec section 6 in service/interface PHPDoc: no arbitrary registration loops, no file-first then root/metadata, no job-lock-first catalog entry.
|
||||||
|
- [ ] Run targeted tests GREEN, full existing regression suite and lint. Obtain fresh task review before Task 2; record results without committing.
|
||||||
|
|
||||||
|
## Task 2: Metadata and status compare-and-set
|
||||||
|
|
||||||
|
**Files:** same catalog/repository files; new `ModelMetadata.php`, `ModelMetadataTest.php`; extend `CatalogContractsTest.php`, `RepositoryTest.php`.
|
||||||
|
|
||||||
|
**Interfaces:** Add to service and repository `modelMetadata(int $modelId): ?array`, `updateModelMetadata(int $modelId, array $expected, array $replacement): array`, `changeFileStatus(int $fileId, string $expectedStatus, string $targetStatus): array`. Replace repository `setFileStatus`; update every caller. Metadata shape is exactly `{title: string, notes: ?string, sourceURL: ?string}`. Status returns normalized full file row. `ModelMetadata` exposes static `validateTitle(string $title): void`, `validateShape(array $metadata): void`, `replacement(array $expected, array $replacement): array`; repository invokes content normalization only after locked expected-state comparison.
|
||||||
|
|
||||||
|
- [ ] Write RED tests for exact keys/types, unknown IDs, whole-tuple stale rejection, same-current-target with stale expectation, no-op timestamp preservation, ABA acceptance, independent-field conflicts, caller rollback and forced later-write failure.
|
||||||
|
- [ ] Write validator tests: Unicode title 1–255 characters, nonblank, no NUL, whitespace preserved; null/empty notes and URL clear to null, other notes whitespace preserved. Source URL <=2048 ASCII characters, absolute HTTP(S), nonempty host, valid port; reject userinfo, controls/whitespace, backslash, malformed percent escapes and unencoded Unicode; accept query/fragment, IP literals and punycode. Never fetch.
|
||||||
|
- [ ] Test unchanged legacy empty/invalid URL and other unchanged metadata preservation. Compare expected raw tuple before validating changed content; changed fields alone are normalized. Share title validation with creation/legacy creation, including NUL rejection.
|
||||||
|
- [ ] Implement metadata locking with PostgreSQL `FOR NO KEY UPDATE`, then strict expected comparison and normalized changed-field update of only name/notes/source_url plus updated_at. Return tuple; omit UPDATE on effective no-op. Preserve added_at, folder, IDs and files.
|
||||||
|
- [ ] Implement status with file `FOR UPDATE`, exact existing status vocabulary for both values, stale/missing errors and no-op lock path. Change only status/updated_at, never ownership/hash/size/path. Test every status pair and preserved unrelated values.
|
||||||
|
- [ ] Run targeted RED/GREEN cycles, full regressions/lint and fresh task review. Do not add revisions or state-machine policy.
|
||||||
|
|
||||||
|
## Task 3: Real importer integration and DI
|
||||||
|
|
||||||
|
**Files:** `src/Indexer/LegacyGuessedImportGroupingAdapter.php`, `ImportService.php`, `ImportWorker.php`, `config/container.php`, `tests/Integration/ImportPipelineTest.php`, `tests/Unit/Console/ContainerWiringTest.php`, `ConsoleServicesWiringTest.php`.
|
||||||
|
|
||||||
|
**Interfaces:** Stateless adapter constructed with `CatalogRepositoryInterface`; `modelFor(string $sourcePath, ScannedFile $file): int` alone performs legacy grouping and `ensureModel`. ImportService constructor replaces its catalog dependency with adapter; worker replaces repository with `CatalogServiceInterface`. DI aliases interface to the existing concrete service instance and supplies one PDO to both repositories.
|
||||||
|
|
||||||
|
- [ ] Add RED tests invoking the resolved worker factory, service/interface identity, real adapter behavior across repeated imports and rollback, ambiguous same-title rejection without partial manifest writes, and unchanged legacy directory grouping. Correct misleading claims of explicit grouping.
|
||||||
|
- [ ] Add tests for pending null-owner publication registering an unassigned row, and missing non-null owner/conflicting registration failing without stealing identity. Scan-error terminal items remain unregistered.
|
||||||
|
- [ ] Extract grouping into the stateless adapter without caching model IDs across calls. Update all constructor sites/mocks; repository legacy creation remains outside the general service interface.
|
||||||
|
- [ ] Wire worker through service. Inside existing job transaction, register first, update managed path second, finish item last. Preserve null without casting to 0. Map DomainException only at registration boundary to `ImportException('CATALOG_CONFLICT', ...)`; let other SQL/error behavior remain intact.
|
||||||
|
- [ ] Fix factory instance method `$c` references to `$this->c`. Verify real invocation constructs a worker with shared dependencies and stop callback.
|
||||||
|
- [ ] Inject failure after registration during completion; prove catalog/item/progress rollback, caller transaction preservation where applicable and existing per-item failure recording. Published and source bytes remain; no compensation deletion or claim of filesystem atomicity.
|
||||||
|
- [ ] Run importer, console and catalog tests, full suite/lint and fresh task review.
|
||||||
|
|
||||||
|
## Task 4: PostgreSQL contention evidence and bounded acceptance
|
||||||
|
|
||||||
|
**Files:** new `tests/Integration/PostgresCatalogContractsTest.php`; reuse `RepositoryDatabase.php` and patterns in `PostgresCatalogTest.php`/`CatalogLockStatement.php`; update documentation only after evidence exists.
|
||||||
|
|
||||||
|
- [ ] Use production migrations and separate connections in disposable test schemas. Add deterministic synchronization with bounded lock timeouts, not timing-only sleeps. Prove two stale metadata writers serialize with one conflict; prove status writers serialize with one conflict, including equal-target stale state.
|
||||||
|
- [ ] Hold an ownership mutation on a file and attempt registration replay for its old owner: registration must wait for file lock, then observe conflict after commit. Test detached/null replay and no-op paths. Prove new folders derive from physical path rather than model home.
|
||||||
|
- [ ] Reproduce the metadata/model-to-file versus ownership/file-to-model FK interaction: metadata `FOR NO KEY UPDATE` must coexist with the ownership FK key-share lock and complete without a lock cycle. Record SQL lock ordering root/model/file/item/job where relevant; do not claim arbitrary compositions are safe.
|
||||||
|
- [ ] Prove lock timeout/SQL exceptions escape unchanged, operation savepoint rollback preserves earlier caller writes, and outer success is not committed. Verify ascending bulk ownership locks still hold and status/metadata do not acquire root unnecessarily.
|
||||||
|
- [ ] Run negative-control mutations in the isolated checkout: remove existing-file replay lock and strengthen metadata lock to `FOR UPDATE` separately. The corresponding concurrency test must fail deterministically; restore changes and rerun GREEN. If unavailable, report this evidence gap rather than implying mutation-red coverage.
|
||||||
|
- [ ] Run full local tests, foundation, PHPCS and diff checks, then full isolated PostgreSQL suite. Never target development credentials/data or dirty `/tmp/MeshVault`; use a fresh remote directory and disposable schema. Record exact source hashes, commands, output, counts and skips. Never record credentials.
|
||||||
|
- [ ] Obtain a fresh whole-branch review of spec compliance, lock/FK behavior, failure handling and actual consumer wiring. Fix findings and repeat affected tests plus final full verification.
|
||||||
|
- [ ] Reconcile #28 and related issue acceptance with observed evidence in `docs/project-status.md`; inspect current issue text if access exists, otherwise explicitly leave live reconciliation pending. Do not close #28 or claim M2/M3, explicit grouping, UI, scanner or recovery complete. Record remaining dependencies and ask for integration authorization; no push.
|
||||||
|
|
||||||
|
## Commands and evidence
|
||||||
|
|
||||||
|
Resolve all paths below against the isolated checkout root (use absolute paths in tool calls). Local executable: `C:\Users\ksolo\.config\herd\bin\php83\php.exe`.
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
$php = 'C:\Users\ksolo\.config\herd\bin\php83\php.exe'
|
||||||
|
& $php vendor/phpunit/phpunit/phpunit --filter 'CatalogContractsTest|ModelMetadataTest'
|
||||||
|
& $php vendor/phpunit/phpunit/phpunit --filter 'ImportPipelineTest|ContainerWiringTest|ConsoleServicesWiringTest'
|
||||||
|
& $php vendor/phpunit/phpunit/phpunit --filter PostgresCatalogContractsTest
|
||||||
|
& $php tests/foundation.php
|
||||||
|
& $php vendor/phpunit/phpunit/phpunit
|
||||||
|
& $php vendor/squizlabs/php_codesniffer/bin/phpcs --standard=phpcs.xml.dist
|
||||||
|
git diff --check
|
||||||
|
```
|
||||||
|
|
||||||
|
Run commands from the isolated checkout; prefix script/config paths with that root when invoking tools. Targeted RED means the intended assertion fails, not a bootstrap/dependency failure. GREEN requires exit 0, no unexpected warnings/risky tests and explained skips. PostgreSQL runs require `MESHVAULT_TEST_PG_DSN`, `MESHVAULT_TEST_PG_USER`, `MESHVAULT_TEST_PG_PASSWORD` with isolated-test credentials; confirm actual helper variable names before provisioning. SQLite skips do not prove PostgreSQL locking. Check untracked new files explicitly because `git diff --check` does not cover them.
|
||||||
|
|
||||||
|
## Self-review / handoff
|
||||||
|
|
||||||
|
Tasks 1–4 cover spec sections 3–8; scope and follow-on acceptance remain as specified in sections 1–2. Repository direct-call invariants, raw metadata/no-op semantics, lock order, importer factory invocation, transaction rollback and physical-byte preservation each have an owning test step. No product changes or new test execution are claimed by this plan.
|
||||||
|
|
||||||
|
Recommended execution: subagent-driven, with fresh implementation and review contexts for each task and final whole-branch review. Native execution is also possible, retaining the task gates and a fresh final reviewer. Before implementation, confirm plan and execution approach, create the isolated worktree via the required worktree workflow, and copy the approved spec/plan into it without disturbing untracked originals.
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
# Catalog core foundation — proposed design
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
Build the missing #28 catalog contract before extending UI and importer workflows.
|
||||||
|
The existing PHP/PDO repositories are useful foundations, but folder/name-based
|
||||||
|
inference is not explicit grouping. A logical model must have stable identity,
|
||||||
|
independent of its title and physical files.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Explicit model creation and file assignment, including multipart STL models with
|
||||||
|
individually selected supplementary images/documents.
|
||||||
|
- Atomic catalog operations with rollback and foreign-key integrity on PostgreSQL.
|
||||||
|
- Preserve file IDs, bytes, paths and timestamps when associations change.
|
||||||
|
- Make future metadata, lifecycle, import and removal operations use one contract.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
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. No new framework or runtime dependency.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
outside MVP; separate models cannot silently adopt the same managed path.
|
||||||
|
2. Creating a model is explicit, not an `ensureModel(folder, name)` lookup. Equal
|
||||||
|
titles do not imply equal identity. Empty draft models are allowed so creation
|
||||||
|
and attachment can be separate user actions.
|
||||||
|
3. Assign/reassign/detach is a metadata operation: no copying, renaming or deletion.
|
||||||
|
A model's files may occupy different physical folders; future move operations
|
||||||
|
must preview the actual file set, not assume a single directory. The model retains
|
||||||
|
a required home folder for organization; this does not relocate its files.
|
||||||
|
4. Future import preview defaults to one STL per model. Multipart grouping and
|
||||||
|
supplementary associations must be explicit and confirmed; no automatic grouping
|
||||||
|
based on folder names. This slice supplies the contract, not the preview UI.
|
||||||
|
5. Model removal will detach file records before deleting model-owned metadata.
|
||||||
|
Files and uploaded-media bytes stay on disk. Physical deletion remains outside MVP.
|
||||||
|
|
||||||
|
## Architecture and builder choices
|
||||||
|
|
||||||
|
Keep PHP, PDO, Phinx, Slim and existing repository conventions. Extend
|
||||||
|
`CatalogRepositoryInterface` / `PdoCatalogRepository` rather than introducing a
|
||||||
|
parallel catalog. Add a catalog service boundary for validation and transactions.
|
||||||
|
Its transaction helper must coexist with an outer importer transaction on the same
|
||||||
|
PDO connection without accidentally committing or rolling it back.
|
||||||
|
|
||||||
|
Proposed operations: create model; read aggregate; assign a selected set of existing
|
||||||
|
file IDs; reassign selected files from an expected current owner; detach selected
|
||||||
|
files. Validation occurs before writes; all changes in a multi-file command commit
|
||||||
|
or roll back together. Missing targets, duplicate IDs and stale expected ownership
|
||||||
|
produce explicit domain failures. Lock selected file rows in deterministic ID order
|
||||||
|
on PostgreSQL and recheck ownership under lock. Concurrent reassignment must not
|
||||||
|
silently overwrite another user's decision. SQLite remains the fast default test
|
||||||
|
backend, not proof of PostgreSQL locking.
|
||||||
|
|
||||||
|
Add new migrations, never rewrite applied migrations. Make file ownership nullable
|
||||||
|
and enforce unique managed relative paths across file rows, not just per model.
|
||||||
|
Migration must detect conflicting existing paths and stop with actionable guidance;
|
||||||
|
never merge or delete existing records automatically. Existing rows and IDs survive.
|
||||||
|
Removal service semantics detach first; avoid a model-delete cascade that erases
|
||||||
|
unassigned-file identity. Decide the exact FK action in the implementation plan.
|
||||||
|
|
||||||
|
Remove the current unique model `(folder_id, name)` constraint to permit independent
|
||||||
|
same-title models. Adapt legacy `ensureModel()` so zero matches creates, one match
|
||||||
|
reuses, and multiple matches fail explicitly; never choose an arbitrary model.
|
||||||
|
Its existing ON CONFLICT clause must change with the constraint. Keep this inferred
|
||||||
|
grouping entry point only for importer compatibility, documented as incomplete.
|
||||||
|
Do not claim #20 complete until the importer actually uses a confirmed manifest.
|
||||||
|
|
||||||
|
Builder choices: reuse `PdoRepository` savepoints for nested transactions; require
|
||||||
|
expected ownership for every association command (explicit null means unassigned,
|
||||||
|
not no precondition); preserve file timestamps on association changes. Use RESTRICT
|
||||||
|
on model deletion and explicit detach. Validate canonical managed relative paths
|
||||||
|
with existing path-safety conventions before writes. Migration preflight must also
|
||||||
|
reject noncanonical path aliases rather than silently repair them. Downgrade must
|
||||||
|
fail safely if unassigned files or duplicate same-folder titles prevent restoration
|
||||||
|
of old constraints; no automatic reassignment, renaming or deletion.
|
||||||
|
|
||||||
|
## Acceptance tests
|
||||||
|
|
||||||
|
- Aggregate with two STL parts, selected image and document, with stable identities.
|
||||||
|
- Reassignment/detach changes associations only; files remain byte-for-byte intact.
|
||||||
|
- Invalid target, missing file, stale expected owner or FK failure rolls back all
|
||||||
|
changes; outer transactions remain owned by their caller.
|
||||||
|
- Two PostgreSQL sessions contest the same file; one cannot silently overwrite the
|
||||||
|
other's ownership decision. Test deterministic lock ordering and explicit
|
||||||
|
conflict/deadlock failure; automatic retries are outside this slice.
|
||||||
|
- Migration preserves IDs/data; duplicate managed paths fail without destructive
|
||||||
|
repair. Test forward migration and rollback constraints explicitly.
|
||||||
|
- Default SQLite suite and isolated PostgreSQL suite pass; existing importer tests
|
||||||
|
remain green while its workflow limitations stay documented.
|
||||||
|
|
||||||
|
## Follow-on order
|
||||||
|
|
||||||
|
After this contract, implement confirmed immutable import manifests (#18/#20) and
|
||||||
|
publication recovery (#24/#25), then progress/retry (#26/#27). Catalog CRUD and
|
||||||
|
CSRF/concurrent-edit handling (#29), database-only removal (#30), safe download (#33),
|
||||||
|
and serialized folder/model moves (#31/#32) follow as separately designed slices.
|
||||||
|
|
||||||
|
## Approval boundary
|
||||||
|
|
||||||
|
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.
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
# Catalog shared importer, metadata and lifecycle contracts
|
||||||
|
|
||||||
|
Status: **Full design and implementation plan approved by the user; Task 4 execution authorized. Fresh whole-branch review and integration authorization remain pending.**
|
||||||
|
|
||||||
|
## 1. Intent and approval boundary
|
||||||
|
|
||||||
|
Continue the next bounded #28 slice after `6b9fb54` (catalog identity, service assignment/reassignment/detachment and nested savepoints). Inspection found `main` clean and ahead of `origin/main` by three commits before this document was written.
|
||||||
|
|
||||||
|
The user approved continuing shared importer/metadata/lifecycle contracts and specifically: **“Include title, notes and source URL updates with stale-edit protection.”** Existing approved identity rules remain: independent same-title models, empty drafts, required organizational home folder, single file ownership, persistent unassigned file records, and association changes without filesystem mutation. The user subsequently approved the full design and plan, as recorded in the execution ledger. References below to proposed builder calls preserve the design's rationale; they are now approved defaults, not outstanding approval questions.
|
||||||
|
|
||||||
|
Goal: one practical catalog boundary that current import consumers actually use and future metadata/lifecycle callers can consume without SQL or inference. No new dependencies; use existing PHP/PDO/Phinx/Slim and test infrastructure.
|
||||||
|
|
||||||
|
Non-goals: routes/forms/CSRF, tags/categories/media management, scanner or scheduler, automatic ingestion, duplicate decisions, confirmed manifests, recovery redesign, model/file removal, downloads, moves, or physical deletion. Catalog operations never fetch URLs or inspect/copy/move/delete bytes.
|
||||||
|
|
||||||
|
## 2. Evidence and issue boundaries
|
||||||
|
|
||||||
|
Source inspected: `src/Catalog/CatalogService.php`; catalog repository/interface and `PdoRepository`; `PdoImportJobRepository`; `src/Indexer/ImportService.php` and `ImportWorker.php`; `config/container.php` and worker factory; model/file/identity migrations; `CatalogCoreTest`, `PostgresCatalogTest`, `ImportPipelineTest`, `RepositoryDatabase`, console container wiring tests, prior catalog-core design and `composer.json`.
|
||||||
|
|
||||||
|
Observed baseline:
|
||||||
|
|
||||||
|
- `models.name` is the title (255 characters), with nullable `notes` and `source_url` (2048 characters), `added_at` and `updated_at`. No revision column exists. Equal folder/title pairs are legal.
|
||||||
|
- Files have globally unique canonical `managed_path`, nullable `model_id`, RESTRICT ownership FK, independent physical folder, hash/size/MIME/status and timestamps. Existing statuses: `Present`, `Missing`, `Updated`, `Duplicate`.
|
||||||
|
- Service exposes creation, aggregate and ownership commands only. Import service calls repository `ensureModel`; worker calls repository `aggregate`/`recordFile`, skipping registration for null owners.
|
||||||
|
- Legacy grouping guesses top-level source directory, or source basename for root files. Its current comment/test name calling this “explicit” is misleading.
|
||||||
|
- `recordFile` serializes path creation using the root folder row, but reads an existing file without `FOR UPDATE`; ownership changes lock files but not that root. The existing-path no-op can race reassignment.
|
||||||
|
- `setFileStatus` is unconditional and updates timestamps even for equal values. `PdoRepository` already owns a transaction or uses a unique nested savepoint. Production DI shares one PDO between repositories.
|
||||||
|
- Worker publishes bytes before a DB transaction that updates item path, registers the catalog row and completes progress. That DB transaction is not filesystem atomicity.
|
||||||
|
|
||||||
|
Live issue bodies read on 2026-10-13 from `https://git.keithsolomon.net/api/v1/repos/keith/MeshVault/issues/` (and comments for #28/#18/#20):
|
||||||
|
|
||||||
|
| Issue | State and boundary |
|
||||||
|
| --- | --- |
|
||||||
|
| #28 | Open; blocked by #14/#15/#18. Transactional aggregate, shared contracts and explicit grouping/reassignment. Comment rejects treating importer repository work as full acceptance. |
|
||||||
|
| #14 / #15 | Open; metadata/stable added_at and file identity/lifecycle schema. Existing schema supports this slice, but #15's source path/mtime/format requirements are not completed here. |
|
||||||
|
| #18 | Open; confirmed immutable grouping manifest, attempt history, resulting-file references, duplicate decisions/relationships and recoverable claims remain incomplete. |
|
||||||
|
| #20 | Open; one-STL default and explicit confirmed associations, mount restrictions, destination exclusion and stable scan manifest remain follow-on. Comment explicitly identifies guessed grouping. |
|
||||||
|
| #29 | Open, blocked by #28; routes/forms, association UX, CSRF and concurrent-edit presentation remain separate. Metadata concurrency primitives do not close it. |
|
||||||
|
| #30 | Open, blocked by #28; database-only removal, confirmation and uncataloged-folder view remain separate. File-row lookup is not disk inventory. |
|
||||||
|
| #46 | Open, blocked by #23/#28/#31; integrity rescan, reconciliation, inaccessible-vs-missing distinction and serialization with physical operations remain separate. |
|
||||||
|
| #47 | Open, blocked by #46/#43; missing-file resolution/replacement and last-file removal policy remain separate. |
|
||||||
|
|
||||||
|
The prior design also identifies publication recovery #24/#25 and progress/retry #26/#27 as follow-ons. This document does not certify those issues. Dependencies are not waived by local test success.
|
||||||
|
|
||||||
|
|
||||||
|
## 3. Proposed service/interface API
|
||||||
|
|
||||||
|
Add `MeshVault\Catalog\CatalogServiceInterface`, implemented by existing `CatalogService`; bind interface and concrete name to the same DI instance. Retain signatures for `createModel`, `aggregate`, `assignFiles`, `reassignFiles`, `detachFiles`. Add:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function atomic(callable $operation): mixed;
|
||||||
|
public function file(int $fileId): ?array;
|
||||||
|
public function fileByManagedPath(string $managedPath): ?array;
|
||||||
|
public function registerFile(
|
||||||
|
?int $modelId, string $managedPath, string $hash, int $size,
|
||||||
|
?string $mime = null, string $status = 'Present'
|
||||||
|
): int;
|
||||||
|
public function modelMetadata(int $modelId): ?array;
|
||||||
|
public function updateModelMetadata(int $modelId, array $expected, array $replacement): array;
|
||||||
|
public function changeFileStatus(int $fileId, string $expectedStatus, string $targetStatus): array;
|
||||||
|
```
|
||||||
|
|
||||||
|
`atomic` invokes a zero-argument closure and returns its value; DB-only composition, not retries. Reads validate positive IDs/canonical paths, return null for absence, and never create rows. File lookups return the stored row including nullable owner, physical folder/path, hash, size, MIME, status and timestamps, regardless of assignment. They do not enumerate physical uncataloged files. Normalize integer/null fields consistently across PDO drivers; preserve nullable hash/size rather than coercing null to zero.
|
||||||
|
|
||||||
|
`modelMetadata` returns exactly `{title: string, notes: ?string, sourceURL: ?string}`, mapped from `name`, `notes`, `source_url`; metadata mutation returns the same post-write shape. Aggregate's DB-shaped model remains compatible. Reads are observations, not leases.
|
||||||
|
|
||||||
|
Extend repository interface with lookup and metadata compare/update methods; replace unsafe `setFileStatus` with `changeFileStatus` (update callers/tests, no unconditional escape hatch). Service `registerFile` delegates to strengthened `recordFile`; no second implementation. Repository methods enforce these invariants even when called directly. Keep `ensureModel` only for the legacy adapter below, not on the service interface.
|
||||||
|
|
||||||
|
Errors: `InvalidArgumentException` for malformed input; `DomainException` with distinct tested messages for missing mutation identities, stale state, ambiguous legacy lookup or registration identity conflict. PDO/constraint/lock errors propagate unchanged without hidden retry. Future HTTP response mapping/CSRF is #29 work.
|
||||||
|
|
||||||
|
## 4. Metadata expected state and validation
|
||||||
|
|
||||||
|
**Proposed builder call: compare the full three-field metadata tuple under a model row lock**, not timestamps and not a blind patch. Both arrays must contain exactly `title`, `notes`, `sourceURL`; missing/unknown keys or wrong types fail. Null is a value, never “leave unchanged”. Caller reads the tuple, copies it, edits desired fields and sends original plus replacement. All three expected fields must match the stored tuple with strict string/null equality before any write. Concurrent changes to different metadata fields therefore conflict rather than auto-merge.
|
||||||
|
|
||||||
|
Expected values are the raw read snapshot: validate both arrays' shape/types, but do not trim, canonicalize or apply new content validity rules to expected values. Lock/read and compare the entire raw expected tuple before content validation/normalization of changes. Missing model fails. Even if replacement already equals current data, stale expected data fails first. After a successful comparison, identify changed fields by strict raw replacement-versus-expected equality; validate/normalize only those changed fields. Preserve unchanged fields exactly, including legacy empty or invalid source URLs and empty-string versus null distinctions. A title/notes edit must not reject or silently clear an unchanged legacy URL. To clear a stored empty URL explicitly, send null. If the resulting tuple equals stored data, issue no UPDATE and preserve timestamps. A real change writes changed metadata columns and `updated_at` only; never `added_at`, folder, IDs or files.
|
||||||
|
|
||||||
|
This detects stale writes even when timestamps are equal or multiple writes occur in one PostgreSQL transaction (`CURRENT_TIMESTAMP` is transaction-stable). It deliberately allows A→B→A followed by an expected-A edit: comparison is of present values, not edit history. The builder-reviewed proposed default is whole-tuple current-value comparison with ABA accepted and no revision migration. It explicitly does not detect every historical metadata edit.
|
||||||
|
|
||||||
|
Changed-field replacement rules (proposed; creation validates all supplied metadata):
|
||||||
|
|
||||||
|
- Title: non-null valid UTF-8, 1–255 Unicode characters, not all whitespace; preserve leading/trailing whitespace. Use shared title validation for metadata changes and creation, including legacy adapter creation. Reject NUL for database portability in both paths; this is a proposed shared validation decision, not an assertion that all current creation paths already enforce it. Same title on another model is allowed.
|
||||||
|
- Notes: null clears; exact empty string normalizes to null; preserve other whitespace/newlines, valid UTF-8 without NUL. No artificial new length cap beyond storage limits. Plain text, not HTML processing.
|
||||||
|
- sourceURL: null or exact empty string clears to null. Otherwise at most 2048 characters, ASCII absolute HTTP/HTTPS URL with nonempty host; reject userinfo, literal whitespace/control characters, backslashes, malformed percent escapes and invalid port. Use PHP URL parsing/validation plus explicit component checks. Accept query/fragment, IP literals and punycode hosts; Unicode URLs must be encoded by caller. Do not trim/rewrite accepted values, resolve DNS, follow redirects, fetch previews or make network requests. This is attribution, not an SSRF-safe fetch capability; future fetching needs separate design. Rendering/link escaping belongs to future UI.
|
||||||
|
|
||||||
|
## 5. Status and registration semantics
|
||||||
|
|
||||||
|
`changeFileStatus` accepts only existing exact status strings for both arguments. Lock the file, check existence and expected status, then change only status and `updated_at`. Return the normalized file row. Equal expected/target still locks and validates, then returns without UPDATE. Stale expected state fails even when current status equals target. All pairs among the four existing statuses are supported; this primitive does not assert a scanner observation. No hash/size replacement, new status, automatic deletion or permission-error-to-Missing mapping. Reassignment does not invalidate a physical-file status observation; status changes never overwrite ownership. Status CAS is present-state, not ABA/history protection.
|
||||||
|
|
||||||
|
Registration accepts a positive model ID or explicit null (unassigned), canonical relative path using existing `ManagedPath::validate`, nonempty hash within 64 characters, nonnegative integer size, optional MIME within 128 characters, and valid initial status. Preserve current opaque-hash compatibility rather than silently imposing SHA-256-only validation on fixtures/callers; real importer supplies SHA-256. Reject invalid UTF-8/NUL in textual DB values. Preserve explicit null end-to-end, including worker input and PDO row normalization; never cast null owner to 0. A supplied non-null model ID must exist; a missing referenced model is a domain failure before insert, not a fallback to unassigned.
|
||||||
|
|
||||||
|
For an absent path, insert a new identity, deriving physical folder/name from path rather than model home. For an existing path, lock that file row and compare exact owner (including null), hash and non-null size. Exact match returns the same ID without changing ANY column. MIME/status arguments are initial-insert metadata, not replay identity and never overwrite a row. Null stored hash/size is not a match to supplied non-null values. Different owner/hash/size is a domain conflict, never an upsert or implicit reassignment. Same hash at a different path remains a separate identity; no deduplication policy. Replay after detach/reassignment must fail for the old owner even if bytes match.
|
||||||
|
|
||||||
|
## 6. Transactions and deterministic locks
|
||||||
|
|
||||||
|
Use existing `PdoRepository` transaction/savepoint helper for every mutation, including repository direct calls. No caller-owned commit/rollback, independent connection, or filesystem action inside catalog transactions. Success in an outer transaction remains uncommitted; failure rolls back only the operation savepoint. SQL errors, including PostgreSQL lock timeouts/deadlocks, escape unchanged after rollback. Caller decides whether to retry the entire intent. SQLite is functional coverage, not proof of row locking.
|
||||||
|
|
||||||
|
**Proposed builder call: retain fine-grained ownership locks rather than serialize every mutation on root.** Registration adds `SELECT ... FOR UPDATE` on existing files in PostgreSQL; root locking alone is insufficient. Ordering for a composed transaction:
|
||||||
|
|
||||||
|
1. Root-folder serialization lock first, if creation/registration/legacy lookup needs it; required folder creation proceeds root-to-leaf before any file lock.
|
||||||
|
2. Explicit model row locks for metadata edits use PostgreSQL `FOR NO KEY UPDATE`, ascending model ID if composing several. Metadata changes no referenced key; do not use `FOR UPDATE`, which conflicts with ownership FK key-share locks.
|
||||||
|
3. File row locks use `FOR UPDATE`, ascending file ID across the whole selected set. Owner existence checks are reads, but inserts/ownership updates also acquire implicit FK `KEY SHARE` locks on referenced models (and folder references where applicable); these are not lock-free operations.
|
||||||
|
4. Import item updates/locks then job progress updates, after catalog registration in worker completion.
|
||||||
|
|
||||||
|
Standalone ownership/status commands explicitly lock relevant files only; never later request root/folder or explicit metadata locks. Ownership's implicit model FK key-share locks remain compatible with metadata's `FOR NO KEY UPDATE`, preventing a metadata-then-file versus file-then-FK lock cycle. Metadata locks model only and still serializes other metadata writers. Registration takes and retains root before path lookup: on the existing-row branch, lock the file and compare replay identity, retaining both locks until transaction completion, without creating/acquiring folders afterward. Only on the new-row branch, derive physical folders from the canonical managed path and create/resolve them root-to-leaf before inserting the file. Never acquire root/folder locks after a file lock. Insert FK checks precede later item/job writes. Existence/expected-state comparisons happen under lock, including no-ops. Ownership commands retain complete-selection validation before changing rows, and sorted IDs.
|
||||||
|
|
||||||
|
`atomic` is not a lock-order scheduler. Supported compositions: model creation followed by metadata then ownership/status; or one registration followed by item/job progress. Do not combine registration (which may already lock a file) with later model metadata locks. Multiple file operations in a transaction must acquire globally ascending file IDs (prefer existing bulk ownership methods); callers must not run file-first then registration, nor hold job/item locks then enter catalog mutation. Multi-registration batches are not a supported ordered composition in this slice; a future bulk API must pre-acquire root and globally sorted file locks. Do not claim arbitrary registration loops are deadlock-free. Existing legacy lookup plus job creation is catalog-first. Separate worker planning/claim transactions finish before publication/completion; completion must be reordered from today's item-path-first implementation to catalog-first. Catalog never acquires the filesystem advisory lock. Worker retains its existing filesystem lock outside database work; future #46/#31 coordination is not implemented here.
|
||||||
|
|
||||||
|
Read lookups do not lock. Existing aggregate's multiple reads under default PostgreSQL isolation are not promoted to a guaranteed snapshot here; mutation safety comes from fresh locked comparison. No migration is needed for value comparison. Do not rewrite applied migrations.
|
||||||
|
|
||||||
|
## 7. Actual importer integration and compatibility boundary
|
||||||
|
|
||||||
|
Introduce `LegacyGuessedImportGroupingAdapter` in the indexer layer. Proposed API: `modelFor(string $sourcePath, ScannedFile $file): int`. Move current first-directory/source-basename guessing there; it alone calls repository legacy `ensureModel(folder,title)` (zero matches creates, one reuses, multiple matches reject). Keep the adapter stateless: perform the legacy lookup on each call, with no shared persistent per-manifest/group cache or retained model IDs across calls/imports. Rolled-back creations and later ambiguous matches must not be hidden by cached results. Correct misleading comments/test names to say legacy guessed grouping. Do not expose it as general catalog behavior or claim user-confirmed associations.
|
||||||
|
|
||||||
|
`ImportService` receives the adapter instead of a catalog repository and uses it while constructing current job items inside `jobs->atomic`; retain enumeration/preflight/scan-error behavior. `ImportWorker` receives `CatalogServiceInterface`, uses its aggregate for destination planning and `registerFile` for every successful publication, including null owner (persist an unassigned row rather than skipping registration). In shared-PDO completion, register first, then `updateManagedPath`, then `finishItem`. Registration conflict rolls back completion and follows existing per-item failure reporting, never steals ownership. Proposed: map catalog domain conflicts at this call boundary to `ImportException('CATALOG_CONFLICT', ...)`, avoiding generic copy-failure labeling; do not redesign retry states.
|
||||||
|
|
||||||
|
DI resolves service/interface, adapter, both import consumers and worker factory with one catalog repository and the same PDO as jobs. Update real constructor sites in pipeline/wiring tests, not merely add an unused interface. Include fixing the actual worker factory's undefined `$c` references to `$this->c` in planned implementation scope. The wiring test must resolve and invoke that factory to construct a worker, not merely resolve the factory object, and prove it uses this graph. Catalog remains independent of indexer/filesystem dependencies.
|
||||||
|
|
||||||
|
Publication still precedes DB completion. DB failure may leave verified bytes on disk; never delete as compensation. Existing collision/adoption behavior and crash windows are not fixed or certified here. No confirmed immutable manifests, resulting-file reference schema, attempt history or recoverable-claim redesign. #18/#20 and recovery follow-ons remain open. The adapter is a temporary compatibility exception to intended explicit grouping, not its completion.
|
||||||
|
|
||||||
|
## 8. Required acceptance evidence (future implementation)
|
||||||
|
|
||||||
|
Use production-migration-backed `RepositoryDatabase` fixtures, default local SQLite and isolated PostgreSQL via `MESHVAULT_TEST_PG_DSN`/user/password. No tests or implementation were run for this design.
|
||||||
|
|
||||||
|
Local/service/repository tests:
|
||||||
|
|
||||||
|
- Metadata mapping; title Unicode/255 limit, blank/null rejection, duplicate-title independence; notes clear/preserve rules; URLs valid/invalid schemes, credentials, controls, lengths, ports, malformed escapes, Unicode policy and no network calls.
|
||||||
|
- Full-tuple comparison: stale title, notes or URL rejects all writes; missing/extra keys rejected; same timestamp with changed values conflicts; legacy empty/null values remain distinguishable. Stale expected tuple fails even if desired values equal current values. Pin proposed ABA behavior.
|
||||||
|
- Changed-field validation: title-only and notes-only edits preserve unchanged invalid/empty legacy URLs exactly; unchanged legacy tuples are no-ops, changed invalid URLs fail, explicit null clears, and normalization yielding current values is a no-op. Shared creation/update title validation covers UTF-8, whitespace, length and NUL, including legacy creation.
|
||||||
|
- Metadata/status valid no-ops preserve every column. Actual edits preserve IDs, added_at and unrelated fields; status matrix covers all four states and rejects unknown states. Missing rows are not successful no-ops.
|
||||||
|
- File lookup by ID/path returns assigned and detached/unassigned rows; absent returns null, invalid path/ID fails without writes. Physical uncataloged files do not become records by reading.
|
||||||
|
- Registration replay returns same ID and preserves MIME/status/timestamps; owner/hash/size mismatch and null stored identity fields conflict; old-owner replay after reassignment/detach fails. Different path with same hash remains distinct. Missing model/constraints roll back folder/file creation.
|
||||||
|
- Supported service composition inside caller transaction; rollback restores all rows. Inject failure after an earlier write: prove savepoint rollback, caller sentinel survival and ability to continue/commit or roll back. Repeat with SQL failures, not just validation exceptions.
|
||||||
|
- Actual container-resolved import service/worker use adapter/service and shared PDO; invoke the resolved worker factory to catch undefined `$c` and verify `$this->c` wiring. Reuse the stateless adapter after rolled-back creation and across manifest builds; fresh lookup must detect later ambiguity. Legacy grouping remains unchanged but named honestly; ambiguous title fails without partial job/model rows. Null-owner publication creates an unassigned record without casting to 0; a missing non-null owner fails rather than becoming unassigned. Inject completion failure after registration: catalog/item/progress changes roll back together; failure reporting remains per-item.
|
||||||
|
- Snapshot two STL parts plus image/document bytes, paths, sizes and filesystem mtimes before metadata/status/ownership and failure cases; assert unchanged afterward. Import failure after publication retains published bytes and source bytes; distinguish this from DB rollback.
|
||||||
|
|
||||||
|
PostgreSQL two-session contention tests (distinct backend PIDs, same isolated schema, bounded lock/statement timeouts, no sleeps as synchronization):
|
||||||
|
|
||||||
|
- Metadata writer A holds an uncommitted change; B's edit and apparent no-op must block/time out, not bypass comparison. A commit makes old expected tuple fail on retry; A rollback permits retry. Repeat for status.
|
||||||
|
- Registration versus reassignment/detach in both directions: ownership's file lock prevents early replay return; commit gives conflict, rollback permits same-ID replay. When replay holds the file lock in a caller transaction, reassignment waits until caller completion. Also test conflicting registration of one new path across owners, with root serialization and no duplicate identity.
|
||||||
|
- Metadata/ownership composition: A holds a metadata `FOR NO KEY UPDATE` lock and then requests a file held by B; B assigns/reassigns that file to A's model and its implicit FK key-share must succeed without waiting on A's metadata lock. B completes, then A's metadata-then-ownership composition proceeds. Cover assignment/reassignment, detach, no-ops, commit/rollback and reversed session roles; verify both independent results and continued metadata-writer exclusion. Trace explicit and account for implicit FK locks, not just mock SQL order.
|
||||||
|
- Registration branch ordering: trace root then existing-file replay lock with no subsequent folder acquisition or creation, including no-op/conflict paths and outer-transaction lock retention. For new rows only, verify path-derived nested folders precede insertion, differ from model home where appropriate, and roll back on failure.
|
||||||
|
- Status versus ownership preserves both independent results; registration replay after status change does not reset status. Test same-ID no-op lock retention until outer commit.
|
||||||
|
- Retain sorted multi-file lock-order tests including reversed input and equal-owner no-op. Verify partial lock acquisition followed by timeout releases operation locks on savepoint rollback while preserving caller work (existing NOWAIT pattern). Extend tracing for new row-lock queries without replacing meaningful contention checks with mocks.
|
||||||
|
- Record local and PG results separately; skipped PG coverage is not locking evidence. Run existing import/catalog/migration regression suites and lint during implementation, not drafting.
|
||||||
|
|
||||||
|
## 9. Completion and builder-reviewed proposed defaults
|
||||||
|
|
||||||
|
Slice completion requires implemented contracts, real consumer/DI wiring, explicit legacy boundary, concurrency-safe registration, expected-state metadata/status mutations, and local plus PostgreSQL rollback/no-op/contention evidence. No new schema required. Expected footprint: catalog service/interface/repository, indexer compatibility adapter and constructor wiring, plus focused tests; not routes or filesystem subsystems.
|
||||||
|
|
||||||
|
This supplies another part of #28, not automatic closure. Before closing #28, reconcile all three acceptance bullets against this slice and baseline identity/aggregate evidence, explicitly record that guessed legacy grouping is not explicit confirmed import grouping, and resolve/track open #14/#15/#18 dependencies. #29/#30/#46/#47 and #18/#20 keep independent acceptance criteria. Neither passing tests nor this design closes live issues.
|
||||||
|
|
||||||
|
Builder-reviewed proposed defaults for full-design approval (not a separate questionnaire):
|
||||||
|
|
||||||
|
1. Compare the raw whole current metadata tuple; accept ABA, with no revision migration and no promise to detect every historical edit. Validate/normalize changed fields only and preserve unchanged legacy values.
|
||||||
|
2. Retain the conservative HTTP(S)-only ASCII/encoded URL policy and null/empty rules in section 4 for changed values and creation. Share title validation, including NUL rejection, across creation and metadata changes.
|
||||||
|
3. Use metadata `FOR NO KEY UPDATE` compatible with implicit ownership FK key-share locks; retain root-then-existing-file replay locking and new-row-only path-derived folder creation before insertion. Verify supported metadata/ownership compositions with PostgreSQL contention evidence.
|
||||||
|
4. Use a stateless legacy grouping adapter, preserve nullable ownership without casting to 0 or silently accepting missing non-null owners, and include the worker factory `$c` to `$this->c` fix plus actual factory invocation coverage in planned scope.
|
||||||
|
|
||||||
|
These defaults, APIs, lock composition restrictions and initial-only registration metadata were subsequently approved with the full design and implementation plan. Approval does not close any issue or authorize integration. Execution evidence and remaining limitations are recorded in the task reports and project status.
|
||||||
@@ -0,0 +1,488 @@
|
|||||||
|
# Bounded #28 Catalog Contract — Explicit Grouping & Stable Identity
|
||||||
|
|
||||||
|
**Status:** Design phase — not implemented. Extends Tasks 1-4 contracts without breaking them.
|
||||||
|
**Baseline:** `main` at `9dd8c07` (Tasks 1-4 integrated, #28 catalog core closed).
|
||||||
|
|
||||||
|
## 1. Problem Statement
|
||||||
|
|
||||||
|
The current `LegacyGuessedImportGroupingAdapter` implements **guessed** grouping:
|
||||||
|
- First directory component under source root → model name
|
||||||
|
- Files in source root → model named after source directory
|
||||||
|
- Stateless, fresh lookup on every call via `ensureModel()`
|
||||||
|
- **No user confirmation**, no persistence of grouping decisions
|
||||||
|
|
||||||
|
Gaps from reconciliation table:
|
||||||
|
- **#18**: Confirmed explicit grouping (user confirmation) missing
|
||||||
|
- **#20**: Explicit user-confirmed grouping (not guessed); stable model identity across imports; grouping persistence
|
||||||
|
- **#24**: Crash between publish and suffix save → duplicate suffixed copies; orphaned published files on catalog failure; replay idempotency
|
||||||
|
|
||||||
|
## 2. Design Principles
|
||||||
|
|
||||||
|
1. **Retain PHP/PDO/Phinx and existing repositories** — no schema rewrites, new migrations only
|
||||||
|
2. **Extend, don't replace** — `CatalogServiceInterface`/`CatalogRepositoryInterface` unchanged
|
||||||
|
3. **Explicit grouping is opt-in** — legacy guessed adapter remains for backward compatibility
|
||||||
|
4. **Bounded scope** — no UI, no scheduler, no async workers; pure service/contract layer
|
||||||
|
5. **Lock order preserved** — root → folder → model → file (same as Task 4)
|
||||||
|
6. **Savepoint semantics** — caller transaction owns commit/rollback; catalog operations use savepoints
|
||||||
|
|
||||||
|
## 3. New Contracts (Interfaces)
|
||||||
|
|
||||||
|
### 3.1 Explicit Grouping Adapter Interface
|
||||||
|
|
||||||
|
```php
|
||||||
|
// src/Indexer/Contracts/ExplicitImportGroupingAdapterInterface.php
|
||||||
|
namespace MeshVault\Indexer\Contracts;
|
||||||
|
|
||||||
|
use MeshVault\Indexer\ScannedFile;
|
||||||
|
|
||||||
|
interface ExplicitImportGroupingAdapterInterface
|
||||||
|
{
|
||||||
|
public function modelFor(string $sourcePath, ScannedFile $file, array $context = []): int;
|
||||||
|
public function persistDecisions(int $jobId, array $decisions): void;
|
||||||
|
public function recoverDecisions(int $jobId): array;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 Explicit Grouping Adapter Implementation
|
||||||
|
|
||||||
|
```php
|
||||||
|
// src/Indexer/ExplicitImportGroupingAdapter.php
|
||||||
|
namespace MeshVault\Indexer;
|
||||||
|
|
||||||
|
use MeshVault\Indexer\Contracts\ExplicitImportGroupingAdapterInterface;
|
||||||
|
use MeshVault\Indexer\ScannedFile;
|
||||||
|
use MeshVault\Repository\CatalogRepositoryInterface;
|
||||||
|
use MeshVault\Repository\ImportJobRepositoryInterface;
|
||||||
|
use DomainException;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
final class ExplicitImportGroupingAdapter implements ExplicitImportGroupingAdapterInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CatalogRepositoryInterface $catalog,
|
||||||
|
private readonly ImportJobRepositoryInterface $jobs,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function modelFor(string $sourcePath, ScannedFile $file, array $context = []): int
|
||||||
|
{
|
||||||
|
if (isset($context['model_id'])) {
|
||||||
|
$modelId = (int) $context['model_id'];
|
||||||
|
$aggregate = $this->catalog->aggregate($modelId);
|
||||||
|
if ($aggregate === null) {
|
||||||
|
throw new DomainException('Explicit model not found.');
|
||||||
|
}
|
||||||
|
if (isset($context['model_name']) && $aggregate['model']['name'] !== $context['model_name']) {
|
||||||
|
throw new DomainException('Model title mismatch.');
|
||||||
|
}
|
||||||
|
if (isset($context['folder_path'])) {
|
||||||
|
$expectedFolder = '/' . ltrim($context['folder_path'], '/');
|
||||||
|
if ($aggregate['folder']['path'] !== $expectedFolder) {
|
||||||
|
throw new DomainException('Model folder mismatch.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $modelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isset($context['folder_path']) && isset($context['model_name'])) {
|
||||||
|
$folderPath = '/' . ltrim($context['folder_path'], '/');
|
||||||
|
$modelName = $context['model_name'];
|
||||||
|
if ($context['create_new'] ?? false) {
|
||||||
|
return $this->catalog->atomic(fn () => $this->catalog->createModel($folderPath, $modelName));
|
||||||
|
}
|
||||||
|
return $this->catalog->atomic(fn () => $this->catalog->ensureModel($folderPath, $modelName));
|
||||||
|
}
|
||||||
|
|
||||||
|
$parent = trim(dirname(str_replace('\\', '/', $file->relativePath)), '/');
|
||||||
|
$group = $parent === '' || $parent === '.'
|
||||||
|
? basename(rtrim(str_replace('\\', '/', $sourcePath), '/'))
|
||||||
|
: explode('/', $parent)[0];
|
||||||
|
return $this->catalog->atomic(fn () => $this->catalog->ensureModel('/' . $group, $group));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function persistDecisions(int $jobId, array $decisions): void
|
||||||
|
{
|
||||||
|
$this->jobs->atomic(function () use ($jobId, $decisions) {
|
||||||
|
foreach ($decisions as $decision) {
|
||||||
|
$this->jobs->recordGroupingDecision(
|
||||||
|
$jobId,
|
||||||
|
$decision['source_path'],
|
||||||
|
$decision['model_id'],
|
||||||
|
$decision['context'] ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recoverDecisions(int $jobId): array
|
||||||
|
{
|
||||||
|
return $this->jobs->getGroupingDecisions($jobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 Import Job Repository Extensions
|
||||||
|
|
||||||
|
```php
|
||||||
|
// src/Repository/ImportJobRepositoryInterface.php (additions)
|
||||||
|
namespace MeshVault\Repository;
|
||||||
|
|
||||||
|
interface ImportJobRepositoryInterface
|
||||||
|
{
|
||||||
|
// ... existing methods ...
|
||||||
|
public function recordGroupingDecision(int $jobId, string $sourcePath, int $modelId, array $context): void;
|
||||||
|
public function getGroupingDecisions(int $jobId): array;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 Catalog Service Extensions (for stable identity)
|
||||||
|
|
||||||
|
```php
|
||||||
|
// src/Catalog/CatalogServiceInterface.php (additions)
|
||||||
|
namespace MeshVault\Catalog;
|
||||||
|
|
||||||
|
interface CatalogServiceInterface
|
||||||
|
{
|
||||||
|
// ... existing methods ...
|
||||||
|
public function findModel(string $folderPath, string $modelName): ?array;
|
||||||
|
public function listModelsInFolder(string $folderPath): array;
|
||||||
|
public function getOrCreateModel(string $folderPath, string $modelName): array;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Database Migrations
|
||||||
|
|
||||||
|
### 4.1 Migration: `20261014120001_import_grouping_decisions.php`
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
|
||||||
|
final class ImportGroupingDecisions extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$this->table('import_jobs_decisions', ['id' => false, 'primary_key' => ['job_id', 'source_path']])
|
||||||
|
->addColumn('job_id', 'integer', ['signed' => false])
|
||||||
|
->addColumn('source_path', 'text')
|
||||||
|
->addColumn('model_id', 'integer', ['signed' => false])
|
||||||
|
->addColumn('context', 'jsonb', ['default' => '{}'])
|
||||||
|
->addColumn('created_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP'])
|
||||||
|
->addForeignKey('job_id', 'import_jobs', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
|
||||||
|
->addIndex(['model_id'], ['name' => 'idx_import_decisions_model'])
|
||||||
|
->create();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
$this->table('import_jobs_decisions')->drop()->save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 Migration: `20261014120002_model_identity_stability.php`
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
|
||||||
|
final class ModelIdentityStability extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
// No schema changes — this migration documents the stability contract
|
||||||
|
// Models are now immutable in identity (folder_id + name unique)
|
||||||
|
// Files reference models via nullable FK (from Task 4)
|
||||||
|
// Explicit grouping decisions recorded in import_jobs_decisions
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// No-op: identity stability is a contract, not a schema change
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Import Service Integration
|
||||||
|
|
||||||
|
```php
|
||||||
|
// src/Indexer/ImportService.php (modified)
|
||||||
|
namespace MeshVault\Indexer;
|
||||||
|
|
||||||
|
use MeshVault\Indexer\Contracts\ExplicitImportGroupingAdapterInterface;
|
||||||
|
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
|
||||||
|
use MeshVault\Repository\ImportJobRepositoryInterface;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
final readonly class ImportService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private FileEnumeratorInterface $enumerator,
|
||||||
|
private ImportJobRepositoryInterface $jobs,
|
||||||
|
private ExplicitImportGroupingAdapterInterface $groupingAdapter, // Changed from LegacyGuessed
|
||||||
|
private SpaceChecker $space,
|
||||||
|
private string $libraryRoot,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $groupingContext Optional explicit grouping context per file */
|
||||||
|
public function import(string $sourcePath, array $extensions = [], array $groupingContext = []): int
|
||||||
|
{
|
||||||
|
$scan = $this->enumerator->enumerate($sourcePath, $extensions);
|
||||||
|
if ($scan->files === []) {
|
||||||
|
$detail = $scan->errors === [] ? 'No importable files were found.' : $scan->errors[0]->message;
|
||||||
|
throw new ImportException('SCAN_EMPTY', $detail);
|
||||||
|
}
|
||||||
|
$this->space->check($this->libraryRoot, $scan->totalBytes);
|
||||||
|
|
||||||
|
try {
|
||||||
|
return $this->jobs->atomic(
|
||||||
|
fn (): int => $this->createJobWithGrouping($sourcePath, $scan, $groupingContext)
|
||||||
|
);
|
||||||
|
} catch (ImportException $error) {
|
||||||
|
throw $error;
|
||||||
|
} catch (Throwable $error) {
|
||||||
|
throw new ImportException('DB_ERROR', 'Could not persist the import job.', $error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
private function createJobWithGrouping(string $sourcePath, ScanResult $scan, array $groupingContext): int
|
||||||
|
{
|
||||||
|
$items = [];
|
||||||
|
$decisions = [];
|
||||||
|
|
||||||
|
foreach ($scan->files as $file) {
|
||||||
|
$fileContext = $groupingContext[$file->relativePath] ?? $groupingContext['*'] ?? [];
|
||||||
|
$modelId = $this->groupingAdapter->modelFor($sourcePath, $file, $fileContext);
|
||||||
|
|
||||||
|
$items[] = [
|
||||||
|
'source_path' => $file->sourcePath,
|
||||||
|
'size' => $file->size,
|
||||||
|
'model_id' => $modelId,
|
||||||
|
];
|
||||||
|
|
||||||
|
$decisions[] = [
|
||||||
|
'source_path' => $file->sourcePath,
|
||||||
|
'model_id' => $modelId,
|
||||||
|
'context' => $fileContext,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($scan->errors as $error) {
|
||||||
|
$items[] = [
|
||||||
|
'source_path' => $error->sourcePath,
|
||||||
|
'size' => 0,
|
||||||
|
'model_id' => null,
|
||||||
|
'status' => 'Failed',
|
||||||
|
'failure_reason' => $error->code . ': ' . $error->message,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$jobId = $this->jobs->create($sourcePath, $items);
|
||||||
|
|
||||||
|
if ($decisions !== []) {
|
||||||
|
$this->groupingAdapter->persistDecisions($jobId, $decisions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $jobId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Worker Recovery & Idempotency (Addresses #24)
|
||||||
|
|
||||||
|
### 6.1 Current Gap
|
||||||
|
- Worker persists planned name **before** copy
|
||||||
|
- Saves collision suffix **after** publication
|
||||||
|
- Crash between → duplicate suffixed copies on replay
|
||||||
|
- Catalog failure after publish → orphaned file on disk
|
||||||
|
|
||||||
|
### 6.2 Solution: Atomic Publish-Then-Register
|
||||||
|
|
||||||
|
```php
|
||||||
|
// src/Indexer/ImportWorker.php (modified publish method)
|
||||||
|
private function publish(array $plan, string $destination): string
|
||||||
|
{
|
||||||
|
$directory = dirname($destination);
|
||||||
|
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
|
||||||
|
throw new ImportException('COPY_FAILED', 'Cannot create the managed destination directory.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$destination = $this->pathSafety->ensureDestSafe($destination);
|
||||||
|
clearstatcache(true, $destination);
|
||||||
|
|
||||||
|
if (is_file($destination)) {
|
||||||
|
if (hash_file('sha256', $destination) === $plan['hash']) {
|
||||||
|
return $destination;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$this->spaceChecker->check($directory, $plan['size']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRITICAL CHANGE: Copy to TEMP, then atomic rename + catalog register in ONE transaction
|
||||||
|
$tempPath = $destination . '.tmp.' . bin2hex(random_bytes(8));
|
||||||
|
try {
|
||||||
|
$this->copier->copy($plan['source'], $tempPath, $plan['size'], $plan['hash']);
|
||||||
|
|
||||||
|
$managedPath = $this->relative($tempPath);
|
||||||
|
$this->catalog->atomic(function () use ($tempPath, $destination, $plan, &$managedPath) {
|
||||||
|
if (!@rename($tempPath, $destination)) {
|
||||||
|
throw new ImportException('COPY_FAILED', 'Cannot finalize the published file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$managedPath = $this->relative($destination);
|
||||||
|
|
||||||
|
$fileId = $this->catalog->registerFile(
|
||||||
|
$plan['model_id'],
|
||||||
|
$managedPath,
|
||||||
|
$plan['hash'],
|
||||||
|
$plan['size'],
|
||||||
|
$plan['mime'] ?? null,
|
||||||
|
'Present'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->jobs->updateItemManagedPath($plan['item_id'], $managedPath, $fileId);
|
||||||
|
});
|
||||||
|
|
||||||
|
return $destination;
|
||||||
|
} catch (Throwable $error) {
|
||||||
|
if (is_file($tempPath)) {
|
||||||
|
@unlink($tempPath);
|
||||||
|
}
|
||||||
|
throw $error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6.3 New Job Repository Method
|
||||||
|
|
||||||
|
```php
|
||||||
|
// src/Repository/ImportJobRepositoryInterface.php (addition)
|
||||||
|
public function updateItemManagedPath(int $itemId, string $managedPath, int $fileId): void;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Lock Order & Transaction Boundaries
|
||||||
|
|
||||||
|
| Operation | Locks Acquired (Order) | Savepoint |
|
||||||
|
|-----------|------------------------|-----------|
|
||||||
|
| `createModel` | Root → Folder → Model | Yes |
|
||||||
|
| `ensureModel` | Root → Folder → Model (SELECT FOR SHARE) | Yes |
|
||||||
|
| `registerFile` | Root → Folder → File | Yes |
|
||||||
|
| `changeFileOwner` | Root → File (model FK key-share) | Yes |
|
||||||
|
| `updateModelMetadata` | Model (FOR NO KEY UPDATE) | Yes |
|
||||||
|
| `changeFileStatus` | File (FOR UPDATE) | Yes |
|
||||||
|
| **Explicit grouping decide** | Root → Folder → Model (same as ensureModel) | Yes |
|
||||||
|
| **Worker publish+register** | Root → Folder → File (same as registerFile) | Yes |
|
||||||
|
|
||||||
|
**Invariant:** Never hold job/item locks while entering catalog mutations. The worker's file lock (`.meshvault-import.lock`) is **outside** database transactions.
|
||||||
|
|
||||||
|
## 8. Test Plan (RED → GREEN)
|
||||||
|
|
||||||
|
### 8.1 New Test Cases
|
||||||
|
|
||||||
|
| Test | Scenario | Expected |
|
||||||
|
|------|----------|----------|
|
||||||
|
| `testExplicitGroupingWithModelId` | Provide existing model_id in context | Reuses model, validates folder/title match |
|
||||||
|
| `testExplicitGroupingNewModel` | Provide folder_path + model_name + create_new | Creates new model at exact path |
|
||||||
|
| `testExplicitGroupingFindOrCreate` | Provide folder_path + model_name (no create_new) | Finds existing or creates |
|
||||||
|
| `testExplicitGroupingAmbiguityRejected` | Two models match folder/title (legacy data) | DomainException |
|
||||||
|
| `testGroupingDecisionsPersisted` | Import with explicit context | Decisions recoverable after job creation |
|
||||||
|
| `testGroupingDecisionsRecovered` | Resume interrupted job | Reuses recorded model_ids |
|
||||||
|
| `testWorkerPublishAtomic` | Crash simulation between copy/register | No orphan temp, no duplicate suffix |
|
||||||
|
| `testWorkerReplayIdempotent` | Re-run worker on completed item | Adopts existing file, no re-copy |
|
||||||
|
| `testWorkerCatalogFailureRollback` | Catalog error after file copy | Temp cleaned, no orphan on disk |
|
||||||
|
| `testStableModelIdentity` | Multiple imports of same source tree | Same model IDs reused (not recreated) |
|
||||||
|
|
||||||
|
### 8.2 Mutation Tests (Negative Controls)
|
||||||
|
|
||||||
|
| Mutation | Expected Failure |
|
||||||
|
|----------|------------------|
|
||||||
|
| Remove FOR SHARE in `ensureModel` | Ambiguous title not detected |
|
||||||
|
| Remove temp-file atomic rename | Orphan temp files on crash |
|
||||||
|
| Skip decision persistence | Recovered job uses guessed grouping |
|
||||||
|
|
||||||
|
## 9. Backward Compatibility
|
||||||
|
|
||||||
|
- `LegacyGuessedImportGroupingAdapter` **unchanged** — remains default in container
|
||||||
|
- New `ExplicitImportGroupingAdapter` is opt-in via container configuration
|
||||||
|
- No schema changes to existing tables (new table only)
|
||||||
|
- Existing `ImportService::import()` signature extended with optional `$groupingContext`
|
||||||
|
|
||||||
|
## 10. Container Wiring (config/container.php)
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Legacy (default)
|
||||||
|
$container->set(ImportService::class, fn ($c) => new ImportService(
|
||||||
|
$c->get(FileEnumeratorInterface::class),
|
||||||
|
$c->get(ImportJobRepositoryInterface::class),
|
||||||
|
$c->get(LegacyGuessedImportGroupingAdapter::class), // Existing
|
||||||
|
$c->get(SpaceChecker::class),
|
||||||
|
$c->get(string::class, 'library_root'),
|
||||||
|
));
|
||||||
|
|
||||||
|
// Explicit (new, opt-in)
|
||||||
|
$container->set(ExplicitImportGroupingAdapter::class, fn ($c) => new ExplicitImportGroupingAdapter(
|
||||||
|
$c->get(CatalogRepositoryInterface::class),
|
||||||
|
$c->get(ImportJobRepositoryInterface::class),
|
||||||
|
));
|
||||||
|
|
||||||
|
$container->set(ImportService::class, fn ($c) => new ImportService(
|
||||||
|
$c->get(FileEnumeratorInterface::class),
|
||||||
|
$c->get(ImportJobRepositoryInterface::class),
|
||||||
|
$c->get(ExplicitImportGroupingAdapter::class), // New
|
||||||
|
$c->get(SpaceChecker::class),
|
||||||
|
$c->get(string::class, 'library_root'),
|
||||||
|
));
|
||||||
|
```
|
||||||
|
|
||||||
|
## 11. Acceptance Criteria (Per #28 Bullets)
|
||||||
|
|
||||||
|
| Bullet | Evidence |
|
||||||
|
|--------|----------|
|
||||||
|
| **1. Transactional multipart aggregate** | Existing `CatalogService::atomic` + `aggregate()` — Tasks 1-4 |
|
||||||
|
| **2. Shared service contracts consumed by import/metadata/status** | `ImportService` uses `CatalogServiceInterface`; `ImportWorker` uses same; metadata/status via `CatalogService` — Tasks 1-4 |
|
||||||
|
| **3. Explicit reassignment/rollback/FK evidence** | `reassignFiles`, `detachFiles`, `changeFileOwner` with FK KEY SHARE — Task 4 |
|
||||||
|
| **NEW: Explicit confirmed grouping** | `ExplicitImportGroupingAdapter` with persisted decisions — This design |
|
||||||
|
| **NEW: Stable model identity** | `findModel`, `getOrCreateModel`, decision recovery — This design |
|
||||||
|
| **NEW: Crash-safe publish** | Atomic temp-rename + catalog register in savepoint — This design |
|
||||||
|
|
||||||
|
## 12. Out of Scope (Future Milestones)
|
||||||
|
|
||||||
|
- UI for grouping confirmation (#14)
|
||||||
|
- Scanner incremental/resumable (#15)
|
||||||
|
- Missing-file replace/remove UI (#25)
|
||||||
|
- Progress/ETA/pause/resume contracts (#26)
|
||||||
|
- Retry/backoff/dead-letter contracts (#27)
|
||||||
|
- Full-text search/facets (#29)
|
||||||
|
- Duplicate detection/review queue (#30)
|
||||||
|
|
||||||
|
## 13. Implementation Sequence
|
||||||
|
|
||||||
|
1. **Migration 1**: `import_jobs_decisions` table
|
||||||
|
2. **Interface**: `ExplicitImportGroupingAdapterInterface`
|
||||||
|
3. **Implementation**: `ExplicitImportGroupingAdapter`
|
||||||
|
4. **Repository extension**: `recordGroupingDecision`, `getGroupingDecisions`, `updateItemManagedPath`
|
||||||
|
5. **Catalog extension**: `findModel`, `listModelsInFolder`, `getOrCreateModel`
|
||||||
|
6. **ImportService integration**: Optional `$groupingContext` parameter
|
||||||
|
7. **Worker atomic publish**: Temp copy → atomic rename + register in savepoint
|
||||||
|
8. **Tests**: RED → GREEN for all cases in §8
|
||||||
|
9. **Mutation verification**: Two negative controls
|
||||||
|
10. **Documentation**: Update ledger and project-status
|
||||||
|
|
||||||
|
## 14. Risk Assessment
|
||||||
|
|
||||||
|
| Risk | Mitigation |
|
||||||
|
|------|------------|
|
||||||
|
| Lock order violation | All new paths follow root→folder→model→file; code review + mutation tests |
|
||||||
|
| Savepoint leakage | All catalog ops use existing `atomic()` wrapper; no raw transactions |
|
||||||
|
| Decision table growth | One row per file per job; bounded by job size; cleaned with job |
|
||||||
|
| Temp file cleanup on crash | `finally` block in worker; temp files in library root (same FS) |
|
||||||
|
| Backward compatibility break | Legacy adapter unchanged; explicit adapter opt-in only |
|
||||||
|
| Migration failure on existing data | Preflight checks (Task 1 pattern); reject if unassigned/ambiguous |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Next Steps:** Implement RED tests first, then GREEN implementation per sequence in §13. No commits to `main` until fresh whole-branch review.
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Catalog;
|
||||||
|
|
||||||
|
use MeshVault\Repository\CatalogRepositoryInterface;
|
||||||
|
|
||||||
|
final class CatalogService implements CatalogServiceInterface
|
||||||
|
{
|
||||||
|
public function __construct(private readonly CatalogRepositoryInterface $catalog)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @see CatalogServiceInterface::atomic() for supported lock-order compositions. */
|
||||||
|
public function atomic(callable $operation): mixed
|
||||||
|
{
|
||||||
|
return $this->catalog->atomic($operation);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function modelMetadata(int $modelId): ?array
|
||||||
|
{
|
||||||
|
return $this->catalog->modelMetadata($modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateModelMetadata(int $modelId, array $expected, array $replacement): array
|
||||||
|
{
|
||||||
|
return $this->catalog->updateModelMetadata($modelId, $expected, $replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function changeFileStatus(int $fileId, string $expectedStatus, string $targetStatus): array
|
||||||
|
{
|
||||||
|
return $this->catalog->changeFileStatus($fileId, $expectedStatus, $targetStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function file(int $fileId): ?array
|
||||||
|
{
|
||||||
|
return $this->catalog->file($fileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fileByManagedPath(string $managedPath): ?array
|
||||||
|
{
|
||||||
|
return $this->catalog->fileByManagedPath($managedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function registerFile(
|
||||||
|
?int $modelId,
|
||||||
|
string $managedPath,
|
||||||
|
string $hash,
|
||||||
|
int $size,
|
||||||
|
?string $mime = null,
|
||||||
|
string $status = 'Present'
|
||||||
|
): int {
|
||||||
|
return $this->catalog->recordFile($modelId, $managedPath, $hash, $size, $mime, $status);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Catalog;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared public boundary for catalog operations used by importers and consumers.
|
||||||
|
*/
|
||||||
|
interface CatalogServiceInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Compose on the shared connection, using savepoints inside caller transactions.
|
||||||
|
* Success never commits caller work; failure rolls back only this operation.
|
||||||
|
* Supported: creation, then metadata, then ownership/status; or one registration,
|
||||||
|
* then item/job progress. Acquire multiple file locks in globally ascending ID order.
|
||||||
|
* Never enter catalog mutation holding job/item locks, acquire root/folder or
|
||||||
|
* metadata locks after file locks, or follow registration with metadata mutation.
|
||||||
|
* Arbitrary registration loops/batches are not supported: this is not a lock scheduler.
|
||||||
|
* Metadata uses FOR NO KEY UPDATE, compatible with ownership's implicit FK key-share.
|
||||||
|
*/
|
||||||
|
public function atomic(callable $operation): mixed;
|
||||||
|
|
||||||
|
public function modelMetadata(int $modelId): ?array;
|
||||||
|
|
||||||
|
public function updateModelMetadata(int $modelId, array $expected, array $replacement): array;
|
||||||
|
|
||||||
|
public function changeFileStatus(int $fileId, string $expectedStatus, string $targetStatus): array;
|
||||||
|
|
||||||
|
public function file(int $fileId): ?array;
|
||||||
|
|
||||||
|
public function fileByManagedPath(string $managedPath): ?array;
|
||||||
|
|
||||||
|
public function registerFile(
|
||||||
|
?int $modelId,
|
||||||
|
string $managedPath,
|
||||||
|
string $hash,
|
||||||
|
int $size,
|
||||||
|
?string $mime = null,
|
||||||
|
string $status = 'Present'
|
||||||
|
): int;
|
||||||
|
|
||||||
|
public function createModel(string $folderPath, string $modelName): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{model: array, folder: array, files: array}|null
|
||||||
|
*/
|
||||||
|
public function aggregate(int $modelId): ?array;
|
||||||
|
|
||||||
|
public function assignFiles(int $modelId, array $fileIds): void;
|
||||||
|
|
||||||
|
public function reassignFiles(int $expectedModelId, int $targetModelId, array $fileIds): void;
|
||||||
|
|
||||||
|
public function detachFiles(int $expectedModelId, array $fileIds): void;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Indexer\Contracts;
|
||||||
|
|
||||||
|
use MeshVault\Indexer\ScannedFile;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Explicit, user-confirmed import grouping with decision persistence for replay/idempotency.
|
||||||
|
* Contrasts with LegacyGuessedImportGroupingAdapter which performs fresh stateless lookups.
|
||||||
|
*/
|
||||||
|
interface ExplicitImportGroupingAdapterInterface
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Resolve or create a model for the given file under explicit user confirmation.
|
||||||
|
*
|
||||||
|
* @param string $sourcePath Absolute source root
|
||||||
|
* @param ScannedFile $file File with relativePath, size, hash
|
||||||
|
* @param array<string, mixed> $context Optional confirmation context:
|
||||||
|
* - 'model_id' => int: user-selected existing model (must match folder/title)
|
||||||
|
* - 'model_name' => string: user-provided model title
|
||||||
|
* - 'folder_path' => string: user-provided folder path (must exist or be creatable)
|
||||||
|
* - 'create_new' => bool: force new model even if match exists
|
||||||
|
* @return int Model ID (existing or newly created)
|
||||||
|
* @throws \DomainException If context conflicts with existing catalog state
|
||||||
|
* @throws \InvalidArgumentException If context is malformed
|
||||||
|
*/
|
||||||
|
public function modelFor(string $sourcePath, ScannedFile $file, array $context = []): int;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist the user's grouping decision for replay/idempotency.
|
||||||
|
* Called after successful import job creation with confirmed context.
|
||||||
|
*
|
||||||
|
* @param int $jobId Import job ID
|
||||||
|
* @param list<array{source_path: string, model_id: int, context: array}> $decisions
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function persistDecisions(int $jobId, array $decisions): void;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recover grouping decisions for a resumed/interrupted job.
|
||||||
|
* Returns model_id per source_path, or empty array if no decision was recorded.
|
||||||
|
*
|
||||||
|
* @param int $jobId Import job ID
|
||||||
|
* @return array<string, int> Map of source_path => model_id
|
||||||
|
*/
|
||||||
|
public function recoverDecisions(int $jobId): array;
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Indexer;
|
||||||
|
|
||||||
|
use DomainException;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use MeshVault\Indexer\Contracts\ExplicitImportGroupingAdapterInterface;
|
||||||
|
use MeshVault\Repository\CatalogRepositoryInterface;
|
||||||
|
use MeshVault\Repository\ImportJobRepositoryInterface;
|
||||||
|
|
||||||
|
/** Opt-in grouping: never infer identity from source filenames. */
|
||||||
|
final readonly class ExplicitImportGroupingAdapter implements ExplicitImportGroupingAdapterInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CatalogRepositoryInterface $catalog,
|
||||||
|
private ImportJobRepositoryInterface $jobs,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function modelFor(string $sourcePath, ScannedFile $file, array $context = []): int
|
||||||
|
{
|
||||||
|
if (($context['confirmed'] ?? null) !== true) {
|
||||||
|
throw new InvalidArgumentException('Explicit grouping requires confirmed=true.');
|
||||||
|
}
|
||||||
|
if (isset($context['create_new']) && !is_bool($context['create_new'])) {
|
||||||
|
throw new InvalidArgumentException('create_new must be boolean.');
|
||||||
|
}
|
||||||
|
foreach (['folder_path', 'model_name'] as $key) {
|
||||||
|
if (array_key_exists($key, $context) && (!is_string($context[$key]) || trim($context[$key]) === '')) {
|
||||||
|
throw new InvalidArgumentException('Explicit folder and title must be nonempty strings.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (array_key_exists('model_id', $context)) {
|
||||||
|
if (!is_int($context['model_id']) || $context['model_id'] <= 0 || ($context['create_new'] ?? false)) {
|
||||||
|
throw new InvalidArgumentException('Select a positive model ID or create a model, not both.');
|
||||||
|
}
|
||||||
|
$aggregate = $this->catalog->aggregate($context['model_id']);
|
||||||
|
if ($aggregate === null) {
|
||||||
|
throw new DomainException('Explicit model not found.');
|
||||||
|
}
|
||||||
|
if (isset($context['model_name']) && $aggregate['model']['name'] !== $context['model_name']) {
|
||||||
|
throw new DomainException('Model title mismatch.');
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
isset($context['folder_path'])
|
||||||
|
&& $aggregate['folder']['path'] !== $this->folder($context['folder_path'])
|
||||||
|
) {
|
||||||
|
throw new DomainException('Model folder mismatch.');
|
||||||
|
}
|
||||||
|
return $context['model_id'];
|
||||||
|
}
|
||||||
|
if (!isset($context['folder_path'], $context['model_name'])) {
|
||||||
|
throw new InvalidArgumentException('Select a model ID or supply both folder_path and model_name.');
|
||||||
|
}
|
||||||
|
$folder = $this->folder($context['folder_path']);
|
||||||
|
return ($context['create_new'] ?? false)
|
||||||
|
? $this->catalog->createModel($folder, $context['model_name'])
|
||||||
|
: $this->catalog->ensureModel($folder, $context['model_name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function folder(string $path): string
|
||||||
|
{
|
||||||
|
$path = '/' . trim($path, '/');
|
||||||
|
foreach (explode('/', $path) as $component) {
|
||||||
|
if (
|
||||||
|
$component === '.' || $component === '..'
|
||||||
|
|| str_contains($component, '\\') || str_contains($component, "\0")
|
||||||
|
) {
|
||||||
|
throw new InvalidArgumentException('Folder must be a canonical catalog path.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (str_contains($path, '//')) {
|
||||||
|
throw new InvalidArgumentException('Folder must be a canonical catalog path.');
|
||||||
|
}
|
||||||
|
return $path;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function persistDecisions(int $jobId, array $decisions): void
|
||||||
|
{
|
||||||
|
$this->jobs->atomic(function () use ($jobId, $decisions): void {
|
||||||
|
foreach ($decisions as $decision) {
|
||||||
|
$this->jobs->recordGroupingDecision(
|
||||||
|
$jobId,
|
||||||
|
$decision['source_path'],
|
||||||
|
$decision['model_id'],
|
||||||
|
$decision['context'] ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function recoverDecisions(int $jobId): array
|
||||||
|
{
|
||||||
|
return $this->jobs->getGroupingDecisions($jobId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,14 +5,13 @@ declare(strict_types=1);
|
|||||||
namespace MeshVault\Indexer;
|
namespace MeshVault\Indexer;
|
||||||
|
|
||||||
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
|
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
|
||||||
use MeshVault\Repository\CatalogRepositoryInterface;
|
|
||||||
use MeshVault\Repository\ImportJobRepositoryInterface;
|
use MeshVault\Repository\ImportJobRepositoryInterface;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Turns a source tree into a durable import job manifest.
|
* Turns a source tree into a durable import job manifest.
|
||||||
*
|
*
|
||||||
* Grouping policy (explicit, never inferred from file contents): each file is
|
* Legacy guessed grouping (not user-confirmed associations): each file is
|
||||||
* assigned to the model named after its first directory component below the
|
* assigned to the model named after its first directory component below the
|
||||||
* source root; files sitting directly in the source root join a model named
|
* source root; files sitting directly in the source root join a model named
|
||||||
* after the source directory itself. Every model lives in a folder of the same
|
* after the source directory itself. Every model lives in a folder of the same
|
||||||
@@ -24,7 +23,7 @@ final readonly class ImportService
|
|||||||
public function __construct(
|
public function __construct(
|
||||||
private FileEnumeratorInterface $enumerator,
|
private FileEnumeratorInterface $enumerator,
|
||||||
private ImportJobRepositoryInterface $jobs,
|
private ImportJobRepositoryInterface $jobs,
|
||||||
private CatalogRepositoryInterface $catalog,
|
private LegacyGuessedImportGroupingAdapter|Contracts\ExplicitImportGroupingAdapterInterface $groupingAdapter,
|
||||||
private SpaceChecker $space,
|
private SpaceChecker $space,
|
||||||
private string $libraryRoot,
|
private string $libraryRoot,
|
||||||
) {
|
) {
|
||||||
@@ -36,8 +35,11 @@ final readonly class ImportService
|
|||||||
* @param list<string> $extensions Case-insensitive extensions without dots.
|
* @param list<string> $extensions Case-insensitive extensions without dots.
|
||||||
* @throws ImportException SCAN_EMPTY, DISK_FULL, SPACE_CHECK_FAILED, DB_ERROR.
|
* @throws ImportException SCAN_EMPTY, DISK_FULL, SPACE_CHECK_FAILED, DB_ERROR.
|
||||||
*/
|
*/
|
||||||
public function import(string $sourcePath, array $extensions = FileEnumeratorInterface::DEFAULT_EXTENSIONS): int
|
public function import(
|
||||||
{
|
string $sourcePath,
|
||||||
|
array $extensions = FileEnumeratorInterface::DEFAULT_EXTENSIONS,
|
||||||
|
array $groupingContext = [],
|
||||||
|
): int {
|
||||||
$scan = $this->enumerator->enumerate($sourcePath, $extensions);
|
$scan = $this->enumerator->enumerate($sourcePath, $extensions);
|
||||||
if ($scan->files === []) {
|
if ($scan->files === []) {
|
||||||
$detail = $scan->errors === [] ? 'No importable files were found.' : $scan->errors[0]->message;
|
$detail = $scan->errors === [] ? 'No importable files were found.' : $scan->errors[0]->message;
|
||||||
@@ -47,7 +49,15 @@ final readonly class ImportService
|
|||||||
$this->space->check($this->libraryRoot, $scan->totalBytes);
|
$this->space->check($this->libraryRoot, $scan->totalBytes);
|
||||||
try {
|
try {
|
||||||
return $this->jobs->atomic(
|
return $this->jobs->atomic(
|
||||||
fn (): int => $this->jobs->create($sourcePath, $this->manifest($sourcePath, $scan))
|
function () use ($sourcePath, $scan, $groupingContext): int {
|
||||||
|
$decisions = [];
|
||||||
|
$items = $this->manifest($sourcePath, $scan, $groupingContext, $decisions);
|
||||||
|
$jobId = $this->jobs->create($sourcePath, $items);
|
||||||
|
if ($this->groupingAdapter instanceof Contracts\ExplicitImportGroupingAdapterInterface) {
|
||||||
|
$this->groupingAdapter->persistDecisions($jobId, $decisions);
|
||||||
|
}
|
||||||
|
return $jobId;
|
||||||
|
}
|
||||||
);
|
);
|
||||||
} catch (ImportException $error) {
|
} catch (ImportException $error) {
|
||||||
throw $error;
|
throw $error;
|
||||||
@@ -57,17 +67,19 @@ final readonly class ImportService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<array<string, mixed>> */
|
/** @return list<array<string, mixed>> */
|
||||||
private function manifest(string $sourcePath, ScanResult $scan): array
|
private function manifest(string $sourcePath, ScanResult $scan, array $contexts, array &$decisions): array
|
||||||
{
|
{
|
||||||
$models = [];
|
|
||||||
$items = [];
|
$items = [];
|
||||||
foreach ($scan->files as $file) {
|
foreach ($scan->files as $file) {
|
||||||
$group = $this->group($sourcePath, $file);
|
$context = $contexts[$file->relativePath] ?? $contexts['*'] ?? [];
|
||||||
$models[$group] ??= $this->catalog->ensureModel('/' . $group, $group);
|
$modelId = $this->groupingAdapter instanceof Contracts\ExplicitImportGroupingAdapterInterface
|
||||||
|
? $this->groupingAdapter->modelFor($sourcePath, $file, $context)
|
||||||
|
: $this->groupingAdapter->modelFor($sourcePath, $file);
|
||||||
|
$decisions[] = ['source_path' => $file->sourcePath, 'model_id' => $modelId, 'context' => $context];
|
||||||
$items[] = [
|
$items[] = [
|
||||||
'source_path' => $file->sourcePath,
|
'source_path' => $file->sourcePath,
|
||||||
'size' => $file->size,
|
'size' => $file->size,
|
||||||
'model_id' => $models[$group],
|
'model_id' => $modelId,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
// Scan failures become terminal items so the job reports them durably.
|
// Scan failures become terminal items so the job reports them durably.
|
||||||
@@ -82,13 +94,4 @@ final readonly class ImportService
|
|||||||
}
|
}
|
||||||
return $items;
|
return $items;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function group(string $sourcePath, ScannedFile $file): string
|
|
||||||
{
|
|
||||||
$parent = trim(str_replace('\\', '/', dirname($file->relativePath)), '/');
|
|
||||||
if ($parent === '' || $parent === '.') {
|
|
||||||
return basename(rtrim(str_replace('\\', '/', $sourcePath), '/'));
|
|
||||||
}
|
|
||||||
return explode('/', $parent)[0];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -7,10 +7,11 @@ namespace MeshVault\Indexer;
|
|||||||
use MeshVault\Exception\PathSafetyException;
|
use MeshVault\Exception\PathSafetyException;
|
||||||
use MeshVault\Indexer\Filesystem\PathSafety;
|
use MeshVault\Indexer\Filesystem\PathSafety;
|
||||||
use MeshVault\Indexer\SpaceChecker;
|
use MeshVault\Indexer\SpaceChecker;
|
||||||
use MeshVault\Repository\CatalogRepositoryInterface;
|
use MeshVault\Catalog\CatalogServiceInterface;
|
||||||
use MeshVault\Repository\ImportJobRepositoryInterface;
|
use MeshVault\Repository\ImportJobRepositoryInterface;
|
||||||
use Psr\Log\LoggerInterface;
|
use Psr\Log\LoggerInterface;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
use DomainException;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializes every managed-library mutation behind one advisory lock file and
|
* Serializes every managed-library mutation behind one advisory lock file and
|
||||||
@@ -32,7 +33,7 @@ final readonly class ImportWorker
|
|||||||
/** @param null|callable(): bool $shouldStop Consulted before each item. */
|
/** @param null|callable(): bool $shouldStop Consulted before each item. */
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private ImportJobRepositoryInterface $jobs,
|
private ImportJobRepositoryInterface $jobs,
|
||||||
private CatalogRepositoryInterface $catalog,
|
private CatalogServiceInterface $catalog,
|
||||||
private StreamingHasher $hasher,
|
private StreamingHasher $hasher,
|
||||||
private VerifiedCopier $copier,
|
private VerifiedCopier $copier,
|
||||||
private StlValidator $validator,
|
private StlValidator $validator,
|
||||||
@@ -51,6 +52,7 @@ final readonly class ImportWorker
|
|||||||
*/
|
*/
|
||||||
public function runOnce(): ?int
|
public function runOnce(): ?int
|
||||||
{
|
{
|
||||||
|
$this->jobs->assertPublicationAutocommit();
|
||||||
$lock = $this->lock();
|
$lock = $this->lock();
|
||||||
try {
|
try {
|
||||||
// Recovery runs under the lock, so no live worker can be reset.
|
// Recovery runs under the lock, so no live worker can be reset.
|
||||||
@@ -103,32 +105,51 @@ final readonly class ImportWorker
|
|||||||
private function process(int $jobId, array $item): void
|
private function process(int $jobId, array $item): void
|
||||||
{
|
{
|
||||||
$itemId = (int) $item['id'];
|
$itemId = (int) $item['id'];
|
||||||
|
$owned = null;
|
||||||
try {
|
try {
|
||||||
|
// The manifest FK can become NULL on model deletion; durable decisions cannot.
|
||||||
|
// Validate before planning AND recovery so explicit intent never becomes unassigned.
|
||||||
|
$decisions = $this->jobs->getGroupingDecisions($jobId);
|
||||||
|
$source = (string) $item['source_path'];
|
||||||
|
if (array_key_exists($source, $decisions)) {
|
||||||
|
$modelId = $decisions[$source];
|
||||||
|
if (
|
||||||
|
$item['model_id'] === null || (int) $item['model_id'] !== $modelId
|
||||||
|
|| $this->catalog->aggregate($modelId) === null
|
||||||
|
) {
|
||||||
|
throw new ImportException('CATALOG_CONFLICT', 'Explicit grouping model is missing or mismatched.');
|
||||||
|
}
|
||||||
|
}
|
||||||
$plan = $this->plan($item);
|
$plan = $this->plan($item);
|
||||||
$destination = $this->pathSafety->ensureDestSafe(
|
$destination = $this->pathSafety->ensureDestSafe(
|
||||||
$this->libraryRoot . DIRECTORY_SEPARATOR
|
$this->libraryRoot . DIRECTORY_SEPARATOR
|
||||||
. str_replace('/', DIRECTORY_SEPARATOR, $plan['managed_path'])
|
. str_replace('/', DIRECTORY_SEPARATOR, $plan['managed_path'])
|
||||||
);
|
);
|
||||||
$published = $this->publish($plan, $destination);
|
$this->jobs->assertPublicationAutocommit();
|
||||||
|
$published = $this->publish($plan, $destination, $owned);
|
||||||
$relative = $this->relative($published);
|
$relative = $this->relative($published);
|
||||||
// Catalog and progress commit together, so a crash here simply replays.
|
// Catalog and progress commit together, so a crash here simply replays.
|
||||||
$this->jobs->atomic(function () use ($item, $itemId, $plan, $published, $relative): void {
|
$this->catalog->atomic(function () use ($item, $itemId, $plan, $relative): void {
|
||||||
// Update managed_path to the actual published path (with collision suffix if any)
|
// Catalog locks precede item/job locks on the shared PDO connection.
|
||||||
// so import_items.managed_path always points at the published file.
|
try {
|
||||||
$this->jobs->updateManagedPath($itemId, $relative);
|
$this->catalog->registerFile(
|
||||||
if ($item['model_id'] !== null) {
|
$item['model_id'] === null ? null : (int) $item['model_id'],
|
||||||
$this->catalog->recordFile(
|
|
||||||
(int) $item['model_id'],
|
|
||||||
$relative,
|
$relative,
|
||||||
$plan['hash'],
|
$plan['hash'],
|
||||||
$plan['size'],
|
$plan['size'],
|
||||||
null,
|
null,
|
||||||
'Present'
|
'Present'
|
||||||
);
|
);
|
||||||
|
} catch (DomainException $error) {
|
||||||
|
throw new ImportException('CATALOG_CONFLICT', $error->getMessage(), $error);
|
||||||
}
|
}
|
||||||
|
$this->jobs->updateManagedPath($itemId, $relative);
|
||||||
$this->jobs->finishItem($itemId, 'Completed');
|
$this->jobs->finishItem($itemId, 'Completed');
|
||||||
});
|
});
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
|
if ($owned !== null) {
|
||||||
|
$this->compensate($destination, $owned, $plan);
|
||||||
|
}
|
||||||
$this->failItem($jobId, $itemId, $error);
|
$this->failItem($jobId, $itemId, $error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,9 +163,6 @@ final readonly class ImportWorker
|
|||||||
private function plan(array $item): array
|
private function plan(array $item): array
|
||||||
{
|
{
|
||||||
$source = (string) $item['source_path'];
|
$source = (string) $item['source_path'];
|
||||||
if (strtolower(pathinfo($source, PATHINFO_EXTENSION)) === 'stl') {
|
|
||||||
$this->validator->validate($source);
|
|
||||||
}
|
|
||||||
if ($item['managed_path'] !== null && $item['hash'] !== null) {
|
if ($item['managed_path'] !== null && $item['hash'] !== null) {
|
||||||
// Recovered plan: trust the committed intent, never choose a new name.
|
// Recovered plan: trust the committed intent, never choose a new name.
|
||||||
return [
|
return [
|
||||||
@@ -154,12 +172,18 @@ final readonly class ImportWorker
|
|||||||
'size' => (int) $item['size'],
|
'size' => (int) $item['size'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
if (strtolower(pathinfo($source, PATHINFO_EXTENSION)) === 'stl') {
|
||||||
|
$this->validator->validate($source);
|
||||||
|
}
|
||||||
$hash = $this->hasher->hash($source);
|
$hash = $this->hasher->hash($source);
|
||||||
$size = filesize($source);
|
$size = filesize($source);
|
||||||
if ($size === false) {
|
if ($size === false) {
|
||||||
throw new ImportException('SCAN_READ_ERROR', 'Cannot determine the source size.');
|
throw new ImportException('SCAN_READ_ERROR', 'Cannot determine the source size.');
|
||||||
}
|
}
|
||||||
$managed = $this->managedPath($item, $source);
|
$managed = $this->relative($this->copier->selectDestination(
|
||||||
|
$this->libraryRoot . DIRECTORY_SEPARATOR . $this->managedPath($item, $source)
|
||||||
|
));
|
||||||
|
$this->jobs->assertPublicationAutocommit();
|
||||||
$this->jobs->planItem((int) $item['id'], $managed, $hash, $size);
|
$this->jobs->planItem((int) $item['id'], $managed, $hash, $size);
|
||||||
return ['source' => $source, 'managed_path' => $managed, 'hash' => $hash, 'size' => $size];
|
return ['source' => $source, 'managed_path' => $managed, 'hash' => $hash, 'size' => $size];
|
||||||
}
|
}
|
||||||
@@ -181,9 +205,9 @@ final readonly class ImportWorker
|
|||||||
* Publish, or adopt an already-published destination left by a crashed run.
|
* Publish, or adopt an already-published destination left by a crashed run.
|
||||||
*
|
*
|
||||||
* @param array{source: string, managed_path: string, hash: string, size: int} $plan
|
* @param array{source: string, managed_path: string, hash: string, size: int} $plan
|
||||||
* @return string The actual published path (may have collision suffix)
|
* @return string The exact persisted path; collisions never change the plan.
|
||||||
*/
|
*/
|
||||||
private function publish(array $plan, string $destination): string
|
private function publish(array $plan, string $destination, ?array &$owned): string
|
||||||
{
|
{
|
||||||
$directory = dirname($destination);
|
$directory = dirname($destination);
|
||||||
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
|
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
|
||||||
@@ -194,17 +218,48 @@ final readonly class ImportWorker
|
|||||||
clearstatcache(true, $destination);
|
clearstatcache(true, $destination);
|
||||||
if (is_file($destination)) {
|
if (is_file($destination)) {
|
||||||
// Idempotent restart: the planned destination already holds our bytes.
|
// Idempotent restart: the planned destination already holds our bytes.
|
||||||
if (hash_file('sha256', $destination) === $plan['hash']) {
|
if (filesize($destination) === $plan['size'] && hash_file('sha256', $destination) === $plan['hash']) {
|
||||||
return $destination;
|
return $destination;
|
||||||
}
|
}
|
||||||
// Different content at the planned destination: let VerifiedCopier resolve the collision
|
throw new ImportException('COLLISION', 'Persisted destination contains different bytes.');
|
||||||
// by appending a _001 suffix. This keeps restart idempotent (we don't re-copy the same
|
|
||||||
// file) while still allowing two same-basename sources with different content to coexist.
|
|
||||||
} else {
|
} else {
|
||||||
// New file being copied: check free space before copying
|
// New file being copied: check free space before copying
|
||||||
$this->spaceChecker->check($directory, $plan['size']);
|
$this->spaceChecker->check($directory, $plan['size']);
|
||||||
}
|
}
|
||||||
return $this->copier->copy($plan['source'], $destination, $plan['size'], $plan['hash']);
|
if ($this->catalog->fileByManagedPath($plan['managed_path']) !== null) {
|
||||||
|
throw new ImportException('CATALOG_CONFLICT', 'Persisted destination is already cataloged.');
|
||||||
|
}
|
||||||
|
$published = $this->copier->copy($plan['source'], $destination, $plan['size'], $plan['hash'], true);
|
||||||
|
clearstatcache(true, $published);
|
||||||
|
$owned = lstat($published) ?: null;
|
||||||
|
return $published;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only compensate this invocation's publication, never adopted bytes or a replacement. */
|
||||||
|
private function compensate(string $destination, array $owned, array $plan): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->pathSafety->ensureDestSafe($destination);
|
||||||
|
clearstatcache(true, $destination);
|
||||||
|
$current = @lstat($destination);
|
||||||
|
// A committed catalog row (including an ambiguous commit result) wins over cleanup.
|
||||||
|
if ($this->catalog->fileByManagedPath($plan['managed_path']) !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
$current !== false && $current['dev'] === $owned['dev'] && $current['ino'] === $owned['ino']
|
||||||
|
&& is_file($destination) && $current['size'] === $plan['size']
|
||||||
|
&& hash_file('sha256', $destination) === $plan['hash']
|
||||||
|
) {
|
||||||
|
if (!@unlink($destination)) {
|
||||||
|
throw new ImportException('COPY_FAILED', 'Cannot compensate publication.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Throwable $cleanupError) {
|
||||||
|
$this->logger->error('Publication compensation could not be verified.', [
|
||||||
|
'managed_path' => $plan['managed_path'], 'error' => $cleanupError->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function relative(string $published): string
|
private function relative(string $published): string
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Indexer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stateless adapter for legacy guessed import grouping.
|
||||||
|
* Mimics the original grouping logic: top-level directory under source root,
|
||||||
|
* or source directory name for files in the source root.
|
||||||
|
*/
|
||||||
|
final class LegacyGuessedImportGroupingAdapter
|
||||||
|
{
|
||||||
|
public function __construct(private readonly \MeshVault\Repository\CatalogRepositoryInterface $catalog)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function modelFor(string $sourcePath, ScannedFile $file): int
|
||||||
|
{
|
||||||
|
$parent = trim(dirname(str_replace('\\', '/', $file->relativePath)), '/');
|
||||||
|
$group = $parent === '' || $parent === '.'
|
||||||
|
? basename(rtrim(str_replace('\\', '/', $sourcePath), '/'))
|
||||||
|
: explode('/', $parent)[0];
|
||||||
|
// No retained IDs: rollback and later ambiguity must be visible on every call.
|
||||||
|
return $this->catalog->ensureModel('/' . $group, $group);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,12 +37,18 @@ final readonly class VerifiedCopier
|
|||||||
/**
|
/**
|
||||||
* Copy an existing regular source to a filename under the managed library.
|
* Copy an existing regular source to a filename under the managed library.
|
||||||
* Parent must exist. Returns absolute published path; collisions try _001
|
* Parent must exist. Returns absolute published path; collisions try _001
|
||||||
* through _999 before the last extension. Never deletes/overwrites a winner.
|
* through _999 before the last extension. With exact=true, collision fails
|
||||||
|
* without suffixing (for durable worker plans). Never deletes/overwrites a winner.
|
||||||
* Throws PathSafetyException unchanged; ImportException reasons are
|
* Throws PathSafetyException unchanged; ImportException reasons are
|
||||||
* COPY_FAILED, HASH_MISMATCH (size or digest), COLLISION, INVALID_ARGUMENT.
|
* COPY_FAILED, HASH_MISMATCH (size or digest), COLLISION, INVALID_ARGUMENT.
|
||||||
*/
|
*/
|
||||||
public function copy(string $source, string $destination, int $expectedSize, string $expectedSha256): string
|
public function copy(
|
||||||
{
|
string $source,
|
||||||
|
string $destination,
|
||||||
|
int $expectedSize,
|
||||||
|
string $expectedSha256,
|
||||||
|
bool $exact = false,
|
||||||
|
): string {
|
||||||
if ($expectedSize < 0 || preg_match('/\A[0-9a-fA-F]{64}\z/', $expectedSha256) !== 1) {
|
if ($expectedSize < 0 || preg_match('/\A[0-9a-fA-F]{64}\z/', $expectedSha256) !== 1) {
|
||||||
throw new ImportException('INVALID_ARGUMENT', 'Expected a nonnegative size and SHA-256 hex digest.');
|
throw new ImportException('INVALID_ARGUMENT', 'Expected a nonnegative size and SHA-256 hex digest.');
|
||||||
}
|
}
|
||||||
@@ -99,7 +105,7 @@ final readonly class VerifiedCopier
|
|||||||
throw new ImportException('COPY_FAILED', 'Temporary close failed.');
|
throw new ImportException('COPY_FAILED', 'Temporary close failed.');
|
||||||
}
|
}
|
||||||
$output = null;
|
$output = null;
|
||||||
return $this->publish($temporary, $destination);
|
return $this->publish($temporary, $destination, $exact);
|
||||||
} catch (ImportException | PathSafetyException $error) {
|
} catch (ImportException | PathSafetyException $error) {
|
||||||
throw $error;
|
throw $error;
|
||||||
} catch (Throwable $error) {
|
} catch (Throwable $error) {
|
||||||
@@ -121,6 +127,23 @@ final readonly class VerifiedCopier
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Select only; caller must durably persist this name and use exact publication. */
|
||||||
|
public function selectDestination(string $destination): string
|
||||||
|
{
|
||||||
|
$extension = pathinfo($destination, PATHINFO_EXTENSION);
|
||||||
|
$stem = $extension === '' ? $destination : substr($destination, 0, -strlen($extension) - 1);
|
||||||
|
for ($suffix = 0; $suffix <= 999; ++$suffix) {
|
||||||
|
$candidate = $suffix === 0 ? $destination
|
||||||
|
: $stem . sprintf('_%03d', $suffix) . ($extension === '' ? '' : '.' . $extension);
|
||||||
|
$candidate = $this->pathSafety->ensureDestSafe($candidate);
|
||||||
|
clearstatcache(true, $candidate);
|
||||||
|
if (!file_exists($candidate)) {
|
||||||
|
return $candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new ImportException('COLLISION', 'Destination and all 999 suffixes are occupied.');
|
||||||
|
}
|
||||||
|
|
||||||
/** @param resource $stream */
|
/** @param resource $stream */
|
||||||
private function verify($stream, int $size, string $digest): void
|
private function verify($stream, int $size, string $digest): void
|
||||||
{
|
{
|
||||||
@@ -142,11 +165,11 @@ final readonly class VerifiedCopier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function publish(string $temporary, string $destination): string
|
private function publish(string $temporary, string $destination, bool $exact): string
|
||||||
{
|
{
|
||||||
$extension = pathinfo($destination, PATHINFO_EXTENSION);
|
$extension = pathinfo($destination, PATHINFO_EXTENSION);
|
||||||
$stem = $extension === '' ? $destination : substr($destination, 0, -strlen($extension) - 1);
|
$stem = $extension === '' ? $destination : substr($destination, 0, -strlen($extension) - 1);
|
||||||
for ($suffix = 0; $suffix <= 999; ++$suffix) {
|
for ($suffix = 0; $suffix <= ($exact ? 0 : 999); ++$suffix) {
|
||||||
$candidate = $suffix === 0 ? $destination
|
$candidate = $suffix === 0 ? $destination
|
||||||
: $stem . sprintf('_%03d', $suffix) . ($extension === '' ? '' : '.' . $extension);
|
: $stem . sprintf('_%03d', $suffix) . ($extension === '' ? '' : '.' . $extension);
|
||||||
$candidate = $this->pathSafety->ensureDestSafe($candidate);
|
$candidate = $this->pathSafety->ensureDestSafe($candidate);
|
||||||
|
|||||||
@@ -6,10 +6,22 @@ namespace MeshVault\Repository;
|
|||||||
|
|
||||||
interface CatalogRepositoryInterface
|
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 ensureModel(string $folderPath, string $modelName): int;
|
||||||
|
|
||||||
|
/** Read a normalized stored row without acquiring locks or creating rows. */
|
||||||
|
public function file(int $fileId): ?array;
|
||||||
|
|
||||||
|
public function fileByManagedPath(string $managedPath): ?array;
|
||||||
|
|
||||||
public function recordFile(
|
public function recordFile(
|
||||||
int $modelId,
|
?int $modelId,
|
||||||
string $managedPath,
|
string $managedPath,
|
||||||
string $hash,
|
string $hash,
|
||||||
int $size,
|
int $size,
|
||||||
@@ -20,5 +32,20 @@ interface CatalogRepositoryInterface
|
|||||||
/** @return array{model: array, folder: array, files: array}|null */
|
/** @return array{model: array, folder: array, files: array}|null */
|
||||||
public function aggregate(int $modelId): ?array;
|
public function aggregate(int $modelId): ?array;
|
||||||
|
|
||||||
public function setFileStatus(int $fileId, string $status): void;
|
/**
|
||||||
|
* 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;
|
||||||
|
|
||||||
|
/** Raw observation; absent models return null. */
|
||||||
|
public function modelMetadata(int $modelId): ?array;
|
||||||
|
|
||||||
|
/** Full raw named-tuple CAS; validate changed content only after locked comparison. */
|
||||||
|
public function updateModelMetadata(int $modelId, array $expected, array $replacement): array;
|
||||||
|
|
||||||
|
/** Locked status CAS, including no-ops; returns the normalized complete file row. */
|
||||||
|
public function changeFileStatus(int $fileId, string $expectedStatus, string $targetStatus): array;
|
||||||
}
|
}
|
||||||
@@ -6,12 +6,21 @@ namespace MeshVault\Repository;
|
|||||||
|
|
||||||
interface ImportJobRepositoryInterface
|
interface ImportJobRepositoryInterface
|
||||||
{
|
{
|
||||||
|
/** Confirmed decisions are immutable; identical retries are idempotent. */
|
||||||
|
public function recordGroupingDecision(int $jobId, string $sourcePath, int $modelId, array $context): void;
|
||||||
|
|
||||||
|
/** @return array<string, int> Durable source path to model ID map. */
|
||||||
|
public function getGroupingDecisions(int $jobId): array;
|
||||||
|
|
||||||
public function create(string $sourcePath, array $items): int;
|
public function create(string $sourcePath, array $items): int;
|
||||||
|
|
||||||
public function get(int $jobId): ?array;
|
public function get(int $jobId): ?array;
|
||||||
|
|
||||||
public function items(int $jobId): array;
|
public function items(int $jobId): array;
|
||||||
|
|
||||||
|
/** Fail closed if worker intent would be nested in an uncommitted caller transaction. */
|
||||||
|
public function assertPublicationAutocommit(): void;
|
||||||
|
|
||||||
public function claimNext(): ?array;
|
public function claimNext(): ?array;
|
||||||
|
|
||||||
/** Caller MUST hold the global library flock; never use age-based recovery. */
|
/** Caller MUST hold the global library flock; never use age-based recovery. */
|
||||||
|
|||||||
@@ -6,30 +6,97 @@ namespace MeshVault\Repository;
|
|||||||
|
|
||||||
use DomainException;
|
use DomainException;
|
||||||
use InvalidArgumentException;
|
use InvalidArgumentException;
|
||||||
|
use MeshVault\Catalog\ManagedPath;
|
||||||
|
use MeshVault\Catalog\ModelMetadata;
|
||||||
use PDO;
|
use PDO;
|
||||||
|
|
||||||
final class PdoCatalogRepository extends PdoRepository implements CatalogRepositoryInterface
|
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
|
public function ensureModel(string $folderPath, string $modelName): int
|
||||||
{
|
{
|
||||||
if ($modelName === '') {
|
return $this->createOrFindModel($folderPath, $modelName, true);
|
||||||
throw new InvalidArgumentException('Model name must not be empty.');
|
}
|
||||||
|
|
||||||
|
private function createOrFindModel(string $folderPath, string $modelName, bool $legacy): int
|
||||||
|
{
|
||||||
|
ModelMetadata::validateTitle($modelName);
|
||||||
|
if ($folderPath === '') {
|
||||||
|
throw new InvalidArgumentException('A home folder is required.');
|
||||||
}
|
}
|
||||||
return $this->transaction(function () use ($folderPath, $modelName): int {
|
return $this->transaction(function () use ($folderPath, $modelName, $legacy): int {
|
||||||
|
$this->lockRoot();
|
||||||
$folder = $this->ensureFolder($folderPath);
|
$folder = $this->ensureFolder($folderPath);
|
||||||
$this->execute(
|
if ($legacy) {
|
||||||
'INSERT INTO models (folder_id, name) VALUES (?, ?) ON CONFLICT (folder_id, name) DO NOTHING',
|
$matches = $this->execute(
|
||||||
[$folder, $modelName]
|
'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(
|
return (int) $this->row(
|
||||||
'SELECT id FROM models WHERE folder_id = ? AND name = ?',
|
'INSERT INTO models (folder_id, name) VALUES (?, ?) RETURNING id',
|
||||||
[$folder, $modelName]
|
[$folder, $modelName]
|
||||||
)['id'];
|
)['id'];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function lockRoot(): void
|
||||||
|
{
|
||||||
|
$this->ensureFolder('/');
|
||||||
|
$this->execute("UPDATE folders SET name = name WHERE path = '/'");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function file(int $fileId): ?array
|
||||||
|
{
|
||||||
|
if ($fileId <= 0) {
|
||||||
|
throw new InvalidArgumentException('File ID must be positive.');
|
||||||
|
}
|
||||||
|
return $this->normalizeFile($this->row('SELECT * FROM files WHERE id = ?', [$fileId]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function fileByManagedPath(string $managedPath): ?array
|
||||||
|
{
|
||||||
|
ManagedPath::validate($managedPath);
|
||||||
|
$this->validateText($managedPath);
|
||||||
|
return $this->normalizeFile($this->row('SELECT * FROM files WHERE managed_path = ?', [$managedPath]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeFile(?array $file): ?array
|
||||||
|
{
|
||||||
|
if ($file === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
foreach (['id', 'model_id', 'folder_id', 'size'] as $field) {
|
||||||
|
$file[$field] = $file[$field] === null ? null : (int) $file[$field];
|
||||||
|
}
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateText(string $value, ?int $maximum = null): void
|
||||||
|
{
|
||||||
|
$length = preg_match_all('/./us', $value);
|
||||||
|
if ($length === false || str_contains($value, "\0") || ($maximum !== null && $length > $maximum)) {
|
||||||
|
throw new InvalidArgumentException('File text must be valid UTF-8 without NUL and within length limits.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function recordFile(
|
public function recordFile(
|
||||||
int $modelId,
|
?int $modelId,
|
||||||
string $managedPath,
|
string $managedPath,
|
||||||
string $hash,
|
string $hash,
|
||||||
int $size,
|
int $size,
|
||||||
@@ -37,24 +104,37 @@ final class PdoCatalogRepository extends PdoRepository implements CatalogReposit
|
|||||||
string $status = 'Present'
|
string $status = 'Present'
|
||||||
): int {
|
): int {
|
||||||
$this->validateStatus($status);
|
$this->validateStatus($status);
|
||||||
if ($managedPath === '' || $hash === '' || $size < 0) {
|
ManagedPath::validate($managedPath);
|
||||||
|
$this->validateText($managedPath);
|
||||||
|
$this->validateText($hash, 64);
|
||||||
|
if ($mime !== null) {
|
||||||
|
$this->validateText($mime, 128);
|
||||||
|
}
|
||||||
|
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.');
|
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 {
|
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.
|
// Serialize catalog writers on the root row, including paths assigned to different models.
|
||||||
$this->ensureFolder('/');
|
$this->lockRoot();
|
||||||
$this->execute("UPDATE folders SET name = name WHERE path = '/'");
|
$lock = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? ' FOR UPDATE' : '';
|
||||||
$existing = $this->row('SELECT * FROM files WHERE managed_path = ?', [$managedPath]);
|
$existing = $this->row('SELECT * FROM files WHERE managed_path = ?' . $lock, [$managedPath]);
|
||||||
if ($existing !== null) {
|
if ($existing !== null) {
|
||||||
|
$owner = $existing['model_id'] === null ? null : (int) $existing['model_id'];
|
||||||
if (
|
if (
|
||||||
(int) $existing['model_id'] !== $modelId || $existing['hash'] !== $hash
|
$owner !== $modelId || $existing['hash'] !== $hash
|
||||||
|| (int) $existing['size'] !== $size
|
|| $existing['size'] === null || (int) $existing['size'] !== $size
|
||||||
) {
|
) {
|
||||||
throw new DomainException('Managed path already records a different file.');
|
throw new DomainException('Managed path already records a different file.');
|
||||||
}
|
}
|
||||||
return (int) $existing['id'];
|
return (int) $existing['id'];
|
||||||
}
|
}
|
||||||
$folder = $this->ensureFolder(dirname(str_replace('\\', '/', $managedPath)));
|
if ($modelId !== null && $this->row('SELECT id FROM models WHERE id = ?', [$modelId]) === null) {
|
||||||
|
throw new DomainException('Unknown model.');
|
||||||
|
}
|
||||||
|
$folder = $this->ensureFolder(dirname($managedPath));
|
||||||
$file = $this->row(
|
$file = $this->row(
|
||||||
'INSERT INTO files (model_id, folder_id, name, managed_path, hash, size, mime_type, status)'
|
'INSERT INTO files (model_id, folder_id, name, managed_path, hash, size, mime_type, status)'
|
||||||
. ' VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id',
|
. ' VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id',
|
||||||
@@ -80,17 +160,134 @@ final class PdoCatalogRepository extends PdoRepository implements CatalogReposit
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setFileStatus(int $fileId, string $status): void
|
public function changeFileOwner(array $fileIds, ?int $expectedOwnerId, ?int $targetOwnerId): void
|
||||||
{
|
{
|
||||||
$this->validateStatus($status);
|
if ($fileIds === [] || !array_is_list($fileIds)) {
|
||||||
if (
|
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 modelMetadata(int $modelId): ?array
|
||||||
|
{
|
||||||
|
$this->validateModelId($modelId);
|
||||||
|
return $this->readMetadata($modelId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateModelId(int $modelId): void
|
||||||
|
{
|
||||||
|
if ($modelId <= 0) {
|
||||||
|
throw new InvalidArgumentException('Model ID must be positive.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function readMetadata(int $modelId, string $lock = ''): ?array
|
||||||
|
{
|
||||||
|
$row = $this->row('SELECT name, notes, source_url FROM models WHERE id = ?' . $lock, [$modelId]);
|
||||||
|
return $row === null ? null : [
|
||||||
|
'title' => $row['name'], 'notes' => $row['notes'], 'sourceURL' => $row['source_url'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateModelMetadata(int $modelId, array $expected, array $replacement): array
|
||||||
|
{
|
||||||
|
$this->validateModelId($modelId);
|
||||||
|
ModelMetadata::validateShape($expected);
|
||||||
|
ModelMetadata::validateShape($replacement);
|
||||||
|
return $this->transaction(function () use ($modelId, $expected, $replacement): array {
|
||||||
|
// Compatible with ownership's implicit FK KEY SHARE; never strengthen to FOR UPDATE.
|
||||||
|
$lock = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? ' FOR NO KEY UPDATE' : '';
|
||||||
|
$current = $this->readMetadata($modelId, $lock);
|
||||||
|
if ($current === null) {
|
||||||
|
throw new DomainException('Unknown model.');
|
||||||
|
}
|
||||||
|
foreach ($current as $field => $value) {
|
||||||
|
if ($expected[$field] !== $value) {
|
||||||
|
throw new DomainException('Model metadata has changed.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$next = ModelMetadata::replacement($expected, $replacement);
|
||||||
|
$columns = [];
|
||||||
|
$values = [];
|
||||||
|
foreach (['title' => 'name', 'notes' => 'notes', 'sourceURL' => 'source_url'] as $field => $column) {
|
||||||
|
if ($next[$field] !== $current[$field]) {
|
||||||
|
$columns[] = $column . ' = ?';
|
||||||
|
$values[] = $next[$field];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($columns !== []) {
|
||||||
|
$values[] = $modelId;
|
||||||
|
$this->execute(
|
||||||
|
'UPDATE models SET ' . implode(', ', $columns) . ', updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
|
$values
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return $next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function changeFileStatus(int $fileId, string $expectedStatus, string $targetStatus): array
|
||||||
|
{
|
||||||
|
if ($fileId <= 0) {
|
||||||
|
throw new InvalidArgumentException('File ID must be positive.');
|
||||||
|
}
|
||||||
|
$this->validateStatus($expectedStatus);
|
||||||
|
$this->validateStatus($targetStatus);
|
||||||
|
return $this->transaction(function () use ($fileId, $expectedStatus, $targetStatus): array {
|
||||||
|
$lock = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? ' FOR UPDATE' : '';
|
||||||
|
$file = $this->normalizeFile($this->row('SELECT * FROM files WHERE id = ?' . $lock, [$fileId]));
|
||||||
|
if ($file === null) {
|
||||||
|
throw new DomainException('Unknown file.');
|
||||||
|
}
|
||||||
|
if ($file['status'] !== $expectedStatus) {
|
||||||
|
throw new DomainException('File status has changed.');
|
||||||
|
}
|
||||||
|
if ($expectedStatus === $targetStatus) {
|
||||||
|
return $file;
|
||||||
|
}
|
||||||
$this->execute(
|
$this->execute(
|
||||||
'UPDATE files SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
'UPDATE files SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||||
[$status, $fileId]
|
[$targetStatus, $fileId]
|
||||||
)->rowCount() === 0
|
);
|
||||||
) {
|
return $this->file($fileId);
|
||||||
throw new DomainException('Unknown file.');
|
});
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validateStatus(string $status): void
|
private function validateStatus(string $status): void
|
||||||
|
|||||||
@@ -10,6 +10,46 @@ use PDO;
|
|||||||
|
|
||||||
final class PdoImportJobRepository extends PdoRepository implements ImportJobRepositoryInterface
|
final class PdoImportJobRepository extends PdoRepository implements ImportJobRepositoryInterface
|
||||||
{
|
{
|
||||||
|
public function recordGroupingDecision(int $jobId, string $sourcePath, int $modelId, array $context): void
|
||||||
|
{
|
||||||
|
if ($jobId <= 0 || $modelId <= 0 || $sourcePath === '' || ($context['confirmed'] ?? null) !== true) {
|
||||||
|
throw new InvalidArgumentException('A confirmed decision requires positive IDs and a source path.');
|
||||||
|
}
|
||||||
|
$json = json_encode($context, JSON_THROW_ON_ERROR);
|
||||||
|
$this->transaction(function () use ($jobId, $sourcePath, $modelId, $json): void {
|
||||||
|
$this->execute(
|
||||||
|
'INSERT INTO import_jobs_decisions (job_id, source_path, model_id, context) VALUES (?, ?, ?, ?)'
|
||||||
|
. ' ON CONFLICT (job_id, source_path) DO NOTHING',
|
||||||
|
[$jobId, $sourcePath, $modelId, $json]
|
||||||
|
);
|
||||||
|
$stored = $this->row(
|
||||||
|
'SELECT model_id, context FROM import_jobs_decisions WHERE job_id = ? AND source_path = ?',
|
||||||
|
[$jobId, $sourcePath]
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
(int) $stored['model_id'] !== $modelId
|
||||||
|
|| json_decode($stored['context'], true, 512, JSON_THROW_ON_ERROR)
|
||||||
|
!= json_decode($json, true, 512, JSON_THROW_ON_ERROR)
|
||||||
|
) {
|
||||||
|
throw new DomainException('Grouping decisions are immutable.');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getGroupingDecisions(int $jobId): array
|
||||||
|
{
|
||||||
|
$decisions = [];
|
||||||
|
foreach (
|
||||||
|
$this->execute(
|
||||||
|
'SELECT source_path, model_id FROM import_jobs_decisions WHERE job_id = ? ORDER BY source_path',
|
||||||
|
[$jobId]
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC) as $row
|
||||||
|
) {
|
||||||
|
$decisions[$row['source_path']] = (int) $row['model_id'];
|
||||||
|
}
|
||||||
|
return $decisions;
|
||||||
|
}
|
||||||
|
|
||||||
public function create(string $sourcePath, array $items): int
|
public function create(string $sourcePath, array $items): int
|
||||||
{
|
{
|
||||||
if ($sourcePath === '') {
|
if ($sourcePath === '') {
|
||||||
@@ -70,6 +110,13 @@ final class PdoImportJobRepository extends PdoRepository implements ImportJobRep
|
|||||||
->fetchAll(PDO::FETCH_ASSOC);
|
->fetchAll(PDO::FETCH_ASSOC);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function assertPublicationAutocommit(): void
|
||||||
|
{
|
||||||
|
if ($this->pdo->inTransaction()) {
|
||||||
|
throw new \LogicException('Import publication cannot run inside a caller transaction.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function claimNext(): ?array
|
public function claimNext(): ?array
|
||||||
{
|
{
|
||||||
return $this->transaction(function (): ?array {
|
return $this->transaction(function (): ?array {
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Tests\Integration;
|
||||||
|
|
||||||
|
use ArrayObject;
|
||||||
|
use PDOStatement;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Observe prepared-statement execute attempts, including failed executions.
|
||||||
|
* Does not observe PDO query/exec calls or implicit SQL; retains real locks/results.
|
||||||
|
*/
|
||||||
|
final class CatalogContractsStatement extends PDOStatement
|
||||||
|
{
|
||||||
|
protected function __construct(private ArrayObject $trace)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(?array $params = null): bool
|
||||||
|
{
|
||||||
|
$this->trace[] = $this->queryString;
|
||||||
|
return parent::execute($params);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Tests\Integration;
|
||||||
|
|
||||||
|
use DomainException;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use MeshVault\Indexer\ExplicitImportGroupingAdapter;
|
||||||
|
use MeshVault\Indexer\ScannedFile;
|
||||||
|
use MeshVault\Repository\PdoCatalogRepository;
|
||||||
|
use MeshVault\Repository\PdoImportJobRepository;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ExplicitGroupingTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $database;
|
||||||
|
private \PDO $pdo;
|
||||||
|
private PdoCatalogRepository $catalog;
|
||||||
|
private PdoImportJobRepository $jobs;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->database = tempnam(sys_get_temp_dir(), 'grouping-');
|
||||||
|
$this->pdo = RepositoryDatabase::migrate($this->database);
|
||||||
|
$this->catalog = new PdoCatalogRepository($this->pdo);
|
||||||
|
$this->jobs = new PdoImportJobRepository($this->pdo);
|
||||||
|
self::assertTrue(class_exists(ExplicitImportGroupingAdapter::class), 'Explicit adapter must exist');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
RepositoryDatabase::cleanup($this->database);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testServicePersistsPerFileAndWildcardDecisionsAtomically(): void
|
||||||
|
{
|
||||||
|
$source = RepositoryDatabase::directory('explicit-source');
|
||||||
|
$library = RepositoryDatabase::directory('explicit-library');
|
||||||
|
try {
|
||||||
|
file_put_contents($source . '/a.stl', 'solid a');
|
||||||
|
file_put_contents($source . '/b.stl', 'solid b');
|
||||||
|
$adapter = new ExplicitImportGroupingAdapter($this->catalog, $this->jobs);
|
||||||
|
$service = new \MeshVault\Indexer\ImportService(
|
||||||
|
new \MeshVault\Indexer\FileEnumerator(
|
||||||
|
new \MeshVault\Indexer\Filesystem\LocalFilesystem(),
|
||||||
|
new \MeshVault\Indexer\Filesystem\PathSafety($source)
|
||||||
|
),
|
||||||
|
$this->jobs,
|
||||||
|
$adapter,
|
||||||
|
new \MeshVault\Indexer\SpaceChecker(),
|
||||||
|
$library
|
||||||
|
);
|
||||||
|
$context = ['*' => ['confirmed' => true, 'folder_path' => '/parts', 'model_name' => 'Part']];
|
||||||
|
$job = $service->import($source, ['stl'], $context);
|
||||||
|
$map = $adapter->recoverDecisions($job);
|
||||||
|
self::assertCount(2, $map);
|
||||||
|
self::assertSame($map, $adapter->recoverDecisions($service->import($source, ['stl'], $context)));
|
||||||
|
$before = (int) $this->pdo->query('SELECT count(*) FROM import_jobs')->fetchColumn();
|
||||||
|
try {
|
||||||
|
$service->import($source, ['stl'], ['a.stl' => $context['*']]);
|
||||||
|
self::fail('Incomplete grouping accepted');
|
||||||
|
} catch (\MeshVault\Indexer\ImportException) {
|
||||||
|
self::assertSame($before, (int) $this->pdo->query('SELECT count(*) FROM import_jobs')->fetchColumn());
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
RepositoryDatabase::remove($source);
|
||||||
|
RepositoryDatabase::remove($library);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolve(array $context): int
|
||||||
|
{
|
||||||
|
return (new ExplicitImportGroupingAdapter($this->catalog, $this->jobs))->modelFor(
|
||||||
|
'/source',
|
||||||
|
new ScannedFile('/source/a.stl', 'a.stl', 1, 'model/stl'),
|
||||||
|
$context
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testConfirmationAndCompleteContextAreRequired(): void
|
||||||
|
{
|
||||||
|
foreach (
|
||||||
|
[[], ['confirmed' => true], ['confirmed' => 1, 'model_id' => 1],
|
||||||
|
['confirmed' => true, 'folder_path' => '/x'], ['confirmed' => true, 'model_id' => '1']] as $context
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
$this->resolve($context);
|
||||||
|
self::fail('Malformed or unconfirmed context accepted');
|
||||||
|
} catch (InvalidArgumentException) {
|
||||||
|
self::assertSame(0, (int) $this->pdo->query('SELECT count(*) FROM models')->fetchColumn());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStableIdentityNewIdentityAndAmbiguity(): void
|
||||||
|
{
|
||||||
|
$context = ['confirmed' => true, 'folder_path' => '/parts', 'model_name' => 'Part'];
|
||||||
|
$id = $this->resolve($context);
|
||||||
|
self::assertSame($id, $this->resolve($context));
|
||||||
|
self::assertSame($id, $this->resolve(['model_id' => $id] + $context));
|
||||||
|
self::assertNotSame($id, $this->resolve(['create_new' => true] + $context));
|
||||||
|
$this->expectException(DomainException::class);
|
||||||
|
$this->resolve($context);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIdentityMismatchAndMissingModelAreRejected(): void
|
||||||
|
{
|
||||||
|
$id = $this->catalog->createModel('/parts', 'Part');
|
||||||
|
foreach (
|
||||||
|
[['model_id' => $id, 'model_name' => 'Other'],
|
||||||
|
['model_id' => $id, 'folder_path' => '/other'], ['model_id' => 999999]] as $context
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
$this->resolve(['confirmed' => true] + $context);
|
||||||
|
self::fail('Conflicting identity accepted');
|
||||||
|
} catch (DomainException) {
|
||||||
|
self::assertTrue(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDurableImmutableIdempotentDecisionsAndCallerRollback(): void
|
||||||
|
{
|
||||||
|
$id = $this->resolve(['confirmed' => true, 'folder_path' => '/parts', 'model_name' => 'Part']);
|
||||||
|
$job = $this->jobs->create('/source', [['source_path' => '/source/a.stl', 'size' => 1, 'model_id' => $id]]);
|
||||||
|
$adapter = new ExplicitImportGroupingAdapter($this->catalog, $this->jobs);
|
||||||
|
$decision = ['source_path' => '/source/a.stl', 'model_id' => $id, 'context' => ['confirmed' => true]];
|
||||||
|
$adapter->persistDecisions($job, [$decision]);
|
||||||
|
$adapter->persistDecisions($job, [$decision]);
|
||||||
|
$fresh = new ExplicitImportGroupingAdapter(
|
||||||
|
$this->catalog,
|
||||||
|
new PdoImportJobRepository(RepositoryDatabase::connect($this->database))
|
||||||
|
);
|
||||||
|
self::assertSame(['/source/a.stl' => $id], $fresh->recoverDecisions($job));
|
||||||
|
try {
|
||||||
|
$changed = array_replace($decision, ['context' => ['confirmed' => true, 'extra' => 1]]);
|
||||||
|
$adapter->persistDecisions($job, [$changed]);
|
||||||
|
self::fail('Decision changed');
|
||||||
|
} catch (DomainException) {
|
||||||
|
self::assertSame(['/source/a.stl' => $id], $adapter->recoverDecisions($job));
|
||||||
|
}
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
$other = $this->jobs->create('/other', []);
|
||||||
|
$adapter->persistDecisions($other, [$decision]);
|
||||||
|
self::assertTrue($this->pdo->inTransaction());
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
self::assertSame([], $adapter->recoverDecisions($other));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,9 +42,13 @@ final class ImportPipelineTest extends TestCase
|
|||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
{
|
{
|
||||||
unset($this->pdo, $this->jobs, $this->catalog);
|
unset($this->pdo, $this->jobs, $this->catalog);
|
||||||
@unlink($this->database);
|
RepositoryDatabase::cleanup($this->database);
|
||||||
RepositoryDatabase::remove($this->library);
|
if (isset($this->library)) {
|
||||||
RepositoryDatabase::remove($this->source);
|
RepositoryDatabase::remove($this->library);
|
||||||
|
}
|
||||||
|
if (isset($this->source)) {
|
||||||
|
RepositoryDatabase::remove($this->source);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function service(?SpaceChecker $space = null): ImportService
|
private function service(?SpaceChecker $space = null): ImportService
|
||||||
@@ -53,21 +57,21 @@ final class ImportPipelineTest extends TestCase
|
|||||||
return new ImportService(
|
return new ImportService(
|
||||||
new FileEnumerator(new LocalFilesystem(), $safety),
|
new FileEnumerator(new LocalFilesystem(), $safety),
|
||||||
$this->jobs,
|
$this->jobs,
|
||||||
$this->catalog,
|
new \MeshVault\Indexer\LegacyGuessedImportGroupingAdapter($this->catalog),
|
||||||
$space ?? new SpaceChecker(),
|
$space ?? new SpaceChecker(),
|
||||||
$this->library,
|
$this->library,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function worker(?callable $shouldStop = null): ImportWorker
|
private function worker(?callable $shouldStop = null, array $operations = []): ImportWorker
|
||||||
{
|
{
|
||||||
$safety = new PathSafety($this->library);
|
$safety = new PathSafety($this->library);
|
||||||
$sourceSafety = new PathSafety($this->source);
|
$sourceSafety = new PathSafety($this->source);
|
||||||
return new ImportWorker(
|
return new ImportWorker(
|
||||||
$this->jobs,
|
$this->jobs,
|
||||||
$this->catalog,
|
new \MeshVault\Catalog\CatalogService($this->catalog),
|
||||||
new StreamingHasher($sourceSafety),
|
new StreamingHasher($sourceSafety),
|
||||||
new VerifiedCopier($safety),
|
new VerifiedCopier($safety, $operations),
|
||||||
new StlValidator($sourceSafety),
|
new StlValidator($sourceSafety),
|
||||||
$safety,
|
$safety,
|
||||||
new SpaceChecker(),
|
new SpaceChecker(),
|
||||||
@@ -89,7 +93,187 @@ final class ImportPipelineTest extends TestCase
|
|||||||
return $path;
|
return $path;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testImportCreatesJobWithExplicitSubfolderGrouping(): void
|
public function testNullOwnerIsRegisteredAndScanFailuresAreNot(): void
|
||||||
|
{
|
||||||
|
$source = $this->writeStl('loose.stl');
|
||||||
|
$job = $this->jobs->create($this->source, [
|
||||||
|
['source_path' => $source, 'size' => filesize($source), 'model_id' => null],
|
||||||
|
['source_path' => 'unreadable', 'size' => 0, 'model_id' => null,
|
||||||
|
'status' => 'Failed', 'failure_reason' => 'SCAN_READ_ERROR'],
|
||||||
|
]);
|
||||||
|
$this->worker()->runOnce();
|
||||||
|
$files = $this->pdo->query('SELECT * FROM files')->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
self::assertCount(1, $files);
|
||||||
|
self::assertNull($files[0]['model_id']);
|
||||||
|
self::assertSame('Completed', $this->jobs->items($job)[0]['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRegistrationConflictReportsCatalogConflictAndPreservesBytes(): void
|
||||||
|
{
|
||||||
|
$source = $this->writeStl('planes/wing.stl');
|
||||||
|
$job = $this->service()->import($this->source);
|
||||||
|
$other = $this->catalog->ensureModel('/other', 'other');
|
||||||
|
$id = $this->catalog->recordFile($other, 'planes/wing.stl', hash_file('sha256', $source), filesize($source));
|
||||||
|
$sourceBefore = PhysicalFileSnapshot::capture($this->source);
|
||||||
|
$this->worker()->runOnce();
|
||||||
|
$item = $this->jobs->items($job)[0];
|
||||||
|
self::assertSame('Failed', $item['status']);
|
||||||
|
self::assertStringStartsWith('CATALOG_CONFLICT:', $item['failure_reason']);
|
||||||
|
self::assertSame($other, (int) $this->catalog->file($id)['model_id']);
|
||||||
|
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
|
||||||
|
self::assertFileDoesNotExist($this->library . '/planes/wing.stl');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCompletionRegistersBeforeUpdatingItemPath(): void
|
||||||
|
{
|
||||||
|
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
|
||||||
|
self::markTestSkipped('SQLite ordering assertion trigger.');
|
||||||
|
}
|
||||||
|
$this->writeStl('planes/wing.stl');
|
||||||
|
$job = $this->service()->import($this->source);
|
||||||
|
$this->pdo->exec("CREATE TRIGGER catalog_first BEFORE UPDATE OF managed_path ON import_items"
|
||||||
|
. " WHEN OLD.hash IS NOT NULL AND NOT EXISTS (SELECT 1 FROM files WHERE managed_path = NEW.managed_path)"
|
||||||
|
. " BEGIN SELECT RAISE(ABORT, 'catalog must precede progress'); END");
|
||||||
|
$this->worker()->runOnce();
|
||||||
|
self::assertSame('Completed', $this->jobs->items($job)[0]['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMissingNonNullOwnerFailsRatherThanBecomingUnassigned(): void
|
||||||
|
{
|
||||||
|
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
|
||||||
|
self::markTestSkipped('SQLite fixture deliberately simulates a stale manifest owner.');
|
||||||
|
}
|
||||||
|
$source = $this->writeStl('loose.stl');
|
||||||
|
$this->pdo->exec('PRAGMA foreign_keys = OFF');
|
||||||
|
$job = $this->jobs->create($this->source, [
|
||||||
|
['source_path' => $source, 'size' => filesize($source), 'model_id' => 999999],
|
||||||
|
]);
|
||||||
|
$this->pdo->exec('PRAGMA foreign_keys = ON');
|
||||||
|
$sourceBefore = PhysicalFileSnapshot::capture($this->source);
|
||||||
|
$this->worker()->runOnce();
|
||||||
|
$item = $this->jobs->items($job)[0];
|
||||||
|
self::assertSame('Failed', $item['status']);
|
||||||
|
self::assertStringStartsWith('CATALOG_CONFLICT:', $item['failure_reason']);
|
||||||
|
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
|
||||||
|
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
|
||||||
|
self::assertFileDoesNotExist($this->library . '/unfiled/loose.stl');
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function invalidExplicitOwners(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'deleted before execution' => [true, false, false],
|
||||||
|
'deleted before recovery' => [true, true, false],
|
||||||
|
'different manifest owner' => [false, false, true],
|
||||||
|
'missing historical owner with replacement' => [true, false, true],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\PHPUnit\Framework\Attributes\DataProvider('invalidExplicitOwners')]
|
||||||
|
public function testExplicitOwnershipFailsClosed(bool $delete, bool $recover, bool $replace): void
|
||||||
|
{
|
||||||
|
$source = $this->writeStl('part.stl');
|
||||||
|
$adapter = new \MeshVault\Indexer\ExplicitImportGroupingAdapter($this->catalog, $this->jobs);
|
||||||
|
$service = new ImportService(
|
||||||
|
new FileEnumerator(new LocalFilesystem(), new PathSafety($this->source)),
|
||||||
|
$this->jobs,
|
||||||
|
$adapter,
|
||||||
|
new SpaceChecker(),
|
||||||
|
$this->library,
|
||||||
|
);
|
||||||
|
$model = $this->catalog->createModel('/confirmed', 'Part');
|
||||||
|
$job = $service->import($this->source, ['stl'], [
|
||||||
|
'*' => ['confirmed' => true, 'model_id' => $model],
|
||||||
|
]);
|
||||||
|
$item = $this->jobs->items($job)[0];
|
||||||
|
$destination = $this->library . '/confirmed/part.stl';
|
||||||
|
if ($recover) {
|
||||||
|
$this->jobs->claimNext();
|
||||||
|
$this->jobs->planItem(
|
||||||
|
(int) $item['id'],
|
||||||
|
'confirmed/part.stl',
|
||||||
|
hash_file('sha256', $source),
|
||||||
|
filesize($source)
|
||||||
|
);
|
||||||
|
mkdir(dirname($destination));
|
||||||
|
copy($source, $destination);
|
||||||
|
unlink($source); // Recovery must validate ownership even without the source.
|
||||||
|
}
|
||||||
|
if ($delete) {
|
||||||
|
$this->pdo->exec('DELETE FROM models WHERE id = ' . $model);
|
||||||
|
self::assertNull($this->jobs->items($job)[0]['model_id']);
|
||||||
|
}
|
||||||
|
if ($replace) {
|
||||||
|
$other = $this->catalog->createModel('/replacement', 'Other');
|
||||||
|
$this->pdo->exec('UPDATE import_items SET model_id = ' . $other . ' WHERE id = ' . (int) $item['id']);
|
||||||
|
}
|
||||||
|
self::assertSame([$source => $model], $adapter->recoverDecisions($job));
|
||||||
|
$sourceBefore = PhysicalFileSnapshot::capture($this->source);
|
||||||
|
$this->worker()->runOnce();
|
||||||
|
$item = $this->jobs->items($job)[0];
|
||||||
|
self::assertSame('Failed', $item['status']);
|
||||||
|
self::assertStringStartsWith('CATALOG_CONFLICT:', $item['failure_reason']);
|
||||||
|
self::assertSame('Failed', $this->jobs->get($job)['status']);
|
||||||
|
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
|
||||||
|
self::assertSame([$source => $model], $adapter->recoverDecisions($job));
|
||||||
|
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
|
||||||
|
self::assertFileDoesNotExist($this->library . '/unfiled/part.stl');
|
||||||
|
self::assertFileDoesNotExist($this->library . '/replacement/part.stl');
|
||||||
|
if ($recover) {
|
||||||
|
self::assertSame($item['hash'], hash_file('sha256', $destination));
|
||||||
|
self::assertSame('confirmed/part.stl', $item['managed_path']);
|
||||||
|
} else {
|
||||||
|
self::assertNull($item['managed_path']);
|
||||||
|
self::assertFileDoesNotExist($destination);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCallerTransactionIsRejectedBeforePublication(): void
|
||||||
|
{
|
||||||
|
$this->writeStl('planes/wing.stl');
|
||||||
|
$job = $this->service()->import($this->source);
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
try {
|
||||||
|
$this->worker()->runOnce();
|
||||||
|
self::fail('Uncommitted publication intent accepted');
|
||||||
|
} catch (\LogicException $error) {
|
||||||
|
self::assertStringContainsString('transaction', $error->getMessage());
|
||||||
|
} finally {
|
||||||
|
self::assertTrue($this->pdo->inTransaction());
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
}
|
||||||
|
self::assertSame('Pending', $this->jobs->items($job)[0]['status']);
|
||||||
|
self::assertFileDoesNotExist($this->library . '/planes/wing.stl');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCompletionSqlFailureCompensatesOwnedSuffix(): void
|
||||||
|
{
|
||||||
|
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
|
||||||
|
self::markTestSkipped('SQLite failure-injection trigger.');
|
||||||
|
}
|
||||||
|
$source = $this->writeStl('planes/wing.stl');
|
||||||
|
$job = $this->service()->import($this->source);
|
||||||
|
mkdir($this->library . '/planes');
|
||||||
|
file_put_contents($this->library . '/planes/wing.stl', 'different bytes');
|
||||||
|
$sourceBefore = PhysicalFileSnapshot::capture($this->source);
|
||||||
|
$libraryBefore = PhysicalFileSnapshot::capture($this->library);
|
||||||
|
// Fail after registration and item-path update, during progress refresh.
|
||||||
|
$this->pdo->exec("CREATE TRIGGER reject_completion BEFORE UPDATE ON import_jobs"
|
||||||
|
. " WHEN NEW.status = 'Completed' BEGIN SELECT RAISE(ABORT, 'injected completion failure'); END");
|
||||||
|
$this->worker()->runOnce();
|
||||||
|
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
|
||||||
|
$item = $this->jobs->items($job)[0];
|
||||||
|
self::assertSame('planes/wing_001.stl', $item['managed_path']);
|
||||||
|
self::assertSame('Failed', $item['status']);
|
||||||
|
self::assertStringStartsWith('COPY_FAILED:', $item['failure_reason']);
|
||||||
|
self::assertSame('Failed', $this->jobs->get($job)['status']);
|
||||||
|
self::assertSame($sourceBefore, PhysicalFileSnapshot::capture($this->source));
|
||||||
|
$published = PhysicalFileSnapshot::capture($this->library);
|
||||||
|
self::assertSame($libraryBefore['planes/wing.stl'], $published['planes/wing.stl']);
|
||||||
|
self::assertArrayNotHasKey('planes/wing_001.stl', $published);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testImportCreatesJobWithLegacyGuessedSubfolderGrouping(): void
|
||||||
{
|
{
|
||||||
$this->writeStl('planes/wing.stl');
|
$this->writeStl('planes/wing.stl');
|
||||||
$this->writeStl('planes/body.stl');
|
$this->writeStl('planes/body.stl');
|
||||||
@@ -104,7 +288,7 @@ final class ImportPipelineTest extends TestCase
|
|||||||
$aggregate = $this->catalog->aggregate((int) $item['model_id']);
|
$aggregate = $this->catalog->aggregate((int) $item['model_id']);
|
||||||
$models[$aggregate['model']['name']] = $aggregate['folder']['path'];
|
$models[$aggregate['model']['name']] = $aggregate['folder']['path'];
|
||||||
}
|
}
|
||||||
// Explicit policy: one model per immediate source subfolder; root files join
|
// Legacy guessed grouping: one model per immediate source subfolder; root files join
|
||||||
// a model named after the source directory itself.
|
// a model named after the source directory itself.
|
||||||
self::assertSame('/planes', $models['planes']);
|
self::assertSame('/planes', $models['planes']);
|
||||||
self::assertSame('/' . basename($this->source), $models[basename($this->source)]);
|
self::assertSame('/' . basename($this->source), $models[basename($this->source)]);
|
||||||
@@ -190,6 +374,60 @@ final class ImportPipelineTest extends TestCase
|
|||||||
self::assertCount(1, $this->catalog->aggregate((int) $item['model_id'])['files']);
|
self::assertCount(1, $this->catalog->aggregate((int) $item['model_id'])['files']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testSuffixIntentIsCommittedBeforeLinkAndRecoveryNeedsNoSource(): void
|
||||||
|
{
|
||||||
|
$source = $this->writeStl('planes/wing.stl');
|
||||||
|
$job = $this->service()->import($this->source);
|
||||||
|
mkdir($this->library . '/planes');
|
||||||
|
file_put_contents($this->library . '/planes/wing.stl', 'occupied');
|
||||||
|
$observed = null;
|
||||||
|
$this->worker(null, ['link' => function (string $temp, string $dest) use ($job, &$observed): bool {
|
||||||
|
$reader = RepositoryDatabase::connect($this->database);
|
||||||
|
$observed = $reader->query('SELECT managed_path FROM import_items WHERE job_id = ' . $job)->fetchColumn();
|
||||||
|
return link($temp, $dest);
|
||||||
|
}])->runOnce();
|
||||||
|
self::assertSame('planes/wing_001.stl', $observed);
|
||||||
|
$this->pdo->exec("UPDATE import_items SET status = 'Copying', completed_at = NULL");
|
||||||
|
$this->pdo->exec("UPDATE import_jobs SET status = 'Copying', processed_files = 0");
|
||||||
|
$this->pdo->exec('DELETE FROM files');
|
||||||
|
unlink($source);
|
||||||
|
$this->worker()->runOnce();
|
||||||
|
self::assertSame('Completed', $this->jobs->items($job)[0]['status']);
|
||||||
|
self::assertFileDoesNotExist($this->library . '/planes/wing_002.stl');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPublicationRaceFailsWithoutSilentSuffix(): void
|
||||||
|
{
|
||||||
|
$this->writeStl('planes/wing.stl');
|
||||||
|
$job = $this->service()->import($this->source);
|
||||||
|
$this->worker(null, ['link' => static function (string $temp, string $dest): bool {
|
||||||
|
file_put_contents($dest, 'race winner');
|
||||||
|
return false;
|
||||||
|
}])->runOnce();
|
||||||
|
self::assertSame('Failed', $this->jobs->items($job)[0]['status']);
|
||||||
|
self::assertSame('race winner', file_get_contents($this->library . '/planes/wing.stl'));
|
||||||
|
self::assertFileDoesNotExist($this->library . '/planes/wing_001.stl');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdoptedBytesSurviveCompletionFailure(): void
|
||||||
|
{
|
||||||
|
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) !== 'sqlite') {
|
||||||
|
self::markTestSkipped('SQLite failure-injection trigger.');
|
||||||
|
}
|
||||||
|
$source = $this->writeStl('planes/wing.stl');
|
||||||
|
$job = $this->service()->import($this->source);
|
||||||
|
$item = $this->jobs->items($job)[0];
|
||||||
|
$this->jobs->planItem((int) $item['id'], 'planes/wing.stl', hash_file('sha256', $source), filesize($source));
|
||||||
|
mkdir($this->library . '/planes');
|
||||||
|
copy($source, $this->library . '/planes/wing.stl');
|
||||||
|
$this->pdo->exec("CREATE TRIGGER reject_adoption BEFORE UPDATE ON import_jobs"
|
||||||
|
. " WHEN NEW.status = 'Completed' BEGIN SELECT RAISE(ABORT, 'completion failure'); END");
|
||||||
|
$this->worker()->runOnce();
|
||||||
|
self::assertSame('Failed', $this->jobs->items($job)[0]['status']);
|
||||||
|
self::assertSame(file_get_contents($source), file_get_contents($this->library . '/planes/wing.stl'));
|
||||||
|
self::assertSame(0, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
|
||||||
|
}
|
||||||
|
|
||||||
public function testGracefulStopLeavesJobResumable(): void
|
public function testGracefulStopLeavesJobResumable(): void
|
||||||
{
|
{
|
||||||
$this->writeStl('planes/wing.stl');
|
$this->writeStl('planes/wing.stl');
|
||||||
@@ -349,7 +587,7 @@ final class ImportPipelineTest extends TestCase
|
|||||||
$sourceSafety = new PathSafety($this->source);
|
$sourceSafety = new PathSafety($this->source);
|
||||||
$worker = new ImportWorker(
|
$worker = new ImportWorker(
|
||||||
$this->jobs,
|
$this->jobs,
|
||||||
$this->catalog,
|
new \MeshVault\Catalog\CatalogService($this->catalog),
|
||||||
new StreamingHasher($sourceSafety),
|
new StreamingHasher($sourceSafety),
|
||||||
new VerifiedCopier($safety),
|
new VerifiedCopier($safety),
|
||||||
new StlValidator($sourceSafety),
|
new StlValidator($sourceSafety),
|
||||||
@@ -374,45 +612,35 @@ final class ImportPipelineTest extends TestCase
|
|||||||
|
|
||||||
public function testWorkerUsesCanonicalLibraryRootForRelativePaths(): void
|
public function testWorkerUsesCanonicalLibraryRootForRelativePaths(): void
|
||||||
{
|
{
|
||||||
// Test that the worker uses canonical (realpath) library root for
|
// A trailing separator is safe but differs from the canonical root.
|
||||||
// computing relative paths, not the raw string which might have
|
// Symlink roots are deliberately rejected by PathSafety.
|
||||||
// symlinks or case differences.
|
$rawLibrary = $this->library . DIRECTORY_SEPARATOR;
|
||||||
//
|
|
||||||
// Create library with a symlink in the path
|
|
||||||
$realLibrary = $this->library;
|
|
||||||
$linkLibrary = $realLibrary . '-link';
|
|
||||||
if (!@symlink($realLibrary, $linkLibrary)) {
|
|
||||||
self::markTestSkipped('Host cannot create symlinks.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->writeStl('planes/wing.stl');
|
$this->writeStl('planes/wing.stl');
|
||||||
$job = $this->service()->import($this->source);
|
$job = $this->service()->import($this->source);
|
||||||
|
|
||||||
// Create worker with symlinked library root
|
$safety = new PathSafety($rawLibrary);
|
||||||
$safety = new PathSafety($linkLibrary);
|
self::assertNotSame($rawLibrary, $safety->libraryRoot());
|
||||||
$sourceSafety = new PathSafety($this->source);
|
$sourceSafety = new PathSafety($this->source);
|
||||||
$worker = new ImportWorker(
|
$worker = new ImportWorker(
|
||||||
$this->jobs,
|
$this->jobs,
|
||||||
$this->catalog,
|
new \MeshVault\Catalog\CatalogService($this->catalog),
|
||||||
new StreamingHasher($sourceSafety),
|
new StreamingHasher($sourceSafety),
|
||||||
new VerifiedCopier($safety),
|
new VerifiedCopier($safety),
|
||||||
new StlValidator($sourceSafety),
|
new StlValidator($sourceSafety),
|
||||||
$safety,
|
$safety,
|
||||||
new SpaceChecker(),
|
new SpaceChecker(),
|
||||||
$linkLibrary, // Symlinked path
|
$rawLibrary,
|
||||||
new NullLogger(),
|
new NullLogger(),
|
||||||
);
|
);
|
||||||
|
|
||||||
$worker->runOnce();
|
$worker->runOnce();
|
||||||
$item = $this->jobs->items($job)[0];
|
$item = $this->jobs->items($job)[0];
|
||||||
|
|
||||||
// The managed_path should be relative to the CANONICAL root, not the symlink
|
self::assertSame('Completed', $item['status']);
|
||||||
self::assertStringNotContainsString('link', $item['managed_path']);
|
self::assertStringStartsWith('planes/', $item['managed_path']);
|
||||||
$published = $this->library . DIRECTORY_SEPARATOR
|
$published = $this->library . DIRECTORY_SEPARATOR
|
||||||
. str_replace('/', DIRECTORY_SEPARATOR, $item['managed_path']);
|
. str_replace('/', DIRECTORY_SEPARATOR, $item['managed_path']);
|
||||||
self::assertFileExists($published);
|
self::assertFileExists($published);
|
||||||
|
|
||||||
// Cleanup
|
|
||||||
unlink($linkLibrary);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Tests\Integration;
|
||||||
|
|
||||||
|
use DomainException;
|
||||||
|
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
|
||||||
|
use MeshVault\Indexer\ImportException;
|
||||||
|
use MeshVault\Indexer\ImportService;
|
||||||
|
use MeshVault\Indexer\LegacyGuessedImportGroupingAdapter;
|
||||||
|
use MeshVault\Indexer\ScannedFile;
|
||||||
|
use MeshVault\Indexer\ScanResult;
|
||||||
|
use MeshVault\Indexer\SpaceChecker;
|
||||||
|
use MeshVault\Repository\PdoCatalogRepository;
|
||||||
|
use MeshVault\Repository\PdoImportJobRepository;
|
||||||
|
use PDO;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class LegacyGroupingAdapterTest extends TestCase
|
||||||
|
{
|
||||||
|
private PDO $pdo;
|
||||||
|
private string $database;
|
||||||
|
private string $library;
|
||||||
|
private string $source;
|
||||||
|
private PdoCatalogRepository $catalog;
|
||||||
|
private PdoImportJobRepository $jobs;
|
||||||
|
private LegacyGuessedImportGroupingAdapter $adapter;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-grouping-');
|
||||||
|
$this->pdo = RepositoryDatabase::migrate($this->database);
|
||||||
|
$this->catalog = new PdoCatalogRepository($this->pdo);
|
||||||
|
$this->jobs = new PdoImportJobRepository($this->pdo);
|
||||||
|
$this->adapter = new LegacyGuessedImportGroupingAdapter($this->catalog);
|
||||||
|
$this->library = RepositoryDatabase::directory('library');
|
||||||
|
$this->source = RepositoryDatabase::directory('source');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
unset($this->adapter, $this->catalog, $this->jobs, $this->pdo);
|
||||||
|
RepositoryDatabase::cleanup($this->database);
|
||||||
|
RepositoryDatabase::remove($this->library);
|
||||||
|
RepositoryDatabase::remove($this->source);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSameAdapterResolvesPersistedModelAfterRollback(): void
|
||||||
|
{
|
||||||
|
$file = $this->file('planes/wing.stl');
|
||||||
|
$before = $this->snapshot();
|
||||||
|
$failure = new RuntimeException('Force rollback after grouping.');
|
||||||
|
try {
|
||||||
|
$this->jobs->atomic(function () use ($file, $failure): void {
|
||||||
|
$id = $this->adapter->modelFor($this->source, $file);
|
||||||
|
self::assertSame($id, (int) $this->pdo->query('SELECT id FROM models')->fetchColumn());
|
||||||
|
throw $failure;
|
||||||
|
});
|
||||||
|
self::fail('The transaction must roll back.');
|
||||||
|
} catch (RuntimeException $error) {
|
||||||
|
self::assertSame($failure, $error);
|
||||||
|
}
|
||||||
|
self::assertSame($before, $this->snapshot());
|
||||||
|
|
||||||
|
// SQLite can reuse rolled-back IDs: require an actual persisted row, not a different ID.
|
||||||
|
$id = $this->adapter->modelFor($this->source, $file);
|
||||||
|
$rows = $this->pdo->query(
|
||||||
|
'SELECT m.id, m.name, f.path FROM models m JOIN folders f ON f.id = m.folder_id'
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
self::assertCount(1, $rows);
|
||||||
|
self::assertSame($id, (int) $rows[0]['id']);
|
||||||
|
self::assertSame('planes', $rows[0]['name']);
|
||||||
|
self::assertSame('/planes', $rows[0]['path']);
|
||||||
|
self::assertSame($id, $this->adapter->modelFor($this->source, $file));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSameAdapterRejectsAmbiguityIntroducedBetweenCalls(): void
|
||||||
|
{
|
||||||
|
$file = $this->file('planes/wing.stl');
|
||||||
|
$first = $this->adapter->modelFor($this->source, $file);
|
||||||
|
$second = $this->catalog->createModel('/planes', 'planes');
|
||||||
|
self::assertNotSame($first, $second);
|
||||||
|
$before = $this->snapshot();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->adapter->modelFor($this->source, $file);
|
||||||
|
self::fail('A previously resolved title must not hide new ambiguity.');
|
||||||
|
} catch (DomainException $error) {
|
||||||
|
self::assertSame('Ambiguous model title in home folder.', $error->getMessage());
|
||||||
|
}
|
||||||
|
self::assertSame($before, $this->snapshot());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRepeatedImportsReuseModelsWithTheSameServiceAndAdapter(): void
|
||||||
|
{
|
||||||
|
$service = $this->service([$this->file('planes/wing.stl'), $this->file('planes/nested/body.stl')]);
|
||||||
|
$first = $service->import($this->source);
|
||||||
|
$before = $this->snapshot();
|
||||||
|
$second = $service->import($this->source);
|
||||||
|
|
||||||
|
self::assertNotSame($first, $second);
|
||||||
|
$firstItems = $this->jobs->items($first);
|
||||||
|
$secondItems = $this->jobs->items($second);
|
||||||
|
self::assertCount(2, $firstItems);
|
||||||
|
self::assertCount(2, $secondItems);
|
||||||
|
$model = (int) $firstItems[0]['model_id'];
|
||||||
|
self::assertGreaterThan(0, $model);
|
||||||
|
foreach (array_merge($firstItems, $secondItems) as $item) {
|
||||||
|
self::assertSame($model, (int) $item['model_id']);
|
||||||
|
self::assertSame('Pending', $item['status']);
|
||||||
|
}
|
||||||
|
$after = $this->snapshot();
|
||||||
|
self::assertSame($before['models'], $after['models']);
|
||||||
|
self::assertSame($before['folders'], $after['folders']);
|
||||||
|
self::assertCount(1, $after['models']);
|
||||||
|
self::assertCount(2, $after['import_jobs']);
|
||||||
|
self::assertCount(4, $after['import_items']);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
public function testAmbiguityBetweenImportsRollsBackEarlierManifestModelCreation(): void
|
||||||
|
{
|
||||||
|
$plane = $this->file('planes/wing.stl');
|
||||||
|
$service = $this->service([$plane], [$this->file('new-group/part.stl'), $plane]);
|
||||||
|
$firstJob = $service->import($this->source);
|
||||||
|
$firstModel = (int) $this->jobs->items($firstJob)[0]['model_id'];
|
||||||
|
$duplicate = $this->catalog->createModel('/planes', 'planes');
|
||||||
|
self::assertNotSame($firstModel, $duplicate);
|
||||||
|
$before = $this->snapshot();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// The ordered scan creates /new-group before encountering the now-ambiguous title.
|
||||||
|
$service->import($this->source);
|
||||||
|
self::fail('An ambiguous title must reject the entire import.');
|
||||||
|
} catch (ImportException $error) {
|
||||||
|
self::assertSame('DB_ERROR', $error->reason);
|
||||||
|
self::assertInstanceOf(DomainException::class, $error->getPrevious());
|
||||||
|
self::assertSame('Ambiguous model title in home folder.', $error->getPrevious()->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertFalse($this->pdo->inTransaction());
|
||||||
|
self::assertSame($before, $this->snapshot(), 'No model, folder, job or item may survive the failed import.');
|
||||||
|
self::assertSame(0, (int) $this->pdo->query(
|
||||||
|
"SELECT COUNT(*) FROM folders WHERE path = '/new-group'"
|
||||||
|
)->fetchColumn());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function file(string $relative): ScannedFile
|
||||||
|
{
|
||||||
|
return new ScannedFile($this->source . '/' . $relative, $relative, 12, 'model/stl');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param list<ScannedFile> $files @param list<ScannedFile>|null $nextFiles */
|
||||||
|
private function service(array $files, ?array $nextFiles = null): ImportService
|
||||||
|
{
|
||||||
|
// Control scan order so creation precedes ambiguity. Grouping and persistence remain real.
|
||||||
|
$enumerator = new class ($files, $nextFiles) implements FileEnumeratorInterface {
|
||||||
|
public function __construct(private array $files, private ?array $nextFiles)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public function enumerate(string $sourceRoot, array $extensions = self::DEFAULT_EXTENSIONS): ScanResult
|
||||||
|
{
|
||||||
|
$files = $this->files;
|
||||||
|
$this->files = $this->nextFiles ?? $files;
|
||||||
|
$bytes = array_sum(array_map(static fn (ScannedFile $file): int => $file->size, $files));
|
||||||
|
return new ScanResult($files, $bytes, []);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return new ImportService(
|
||||||
|
$enumerator,
|
||||||
|
$this->jobs,
|
||||||
|
$this->adapter,
|
||||||
|
new SpaceChecker(0.05, static fn (string $path): float => 1000000.0),
|
||||||
|
$this->library,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, list<array<string, mixed>>> */
|
||||||
|
private function snapshot(): array
|
||||||
|
{
|
||||||
|
$snapshot = [];
|
||||||
|
foreach (['folders', 'models', 'import_jobs', 'import_items'] as $table) {
|
||||||
|
$snapshot[$table] = $this->pdo->query('SELECT * FROM ' . $table . ' ORDER BY id')
|
||||||
|
->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
return $snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Tests\Integration;
|
||||||
|
|
||||||
|
/** Sorted relative paths and physical contents/stat data; never catalog row data. */
|
||||||
|
final class PhysicalFileSnapshot
|
||||||
|
{
|
||||||
|
public static function capture(string $root): array
|
||||||
|
{
|
||||||
|
$snapshot = [];
|
||||||
|
$files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator(
|
||||||
|
$root,
|
||||||
|
\FilesystemIterator::SKIP_DOTS
|
||||||
|
));
|
||||||
|
foreach ($files as $file) {
|
||||||
|
$path = $file->getPathname();
|
||||||
|
clearstatcache(true, $path);
|
||||||
|
$relative = str_replace('\\', '/', substr($path, strlen($root) + 1));
|
||||||
|
$snapshot[$relative] = [
|
||||||
|
'bytes' => file_get_contents($path),
|
||||||
|
'size' => filesize($path),
|
||||||
|
'mtime' => filemtime($path),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
ksort($snapshot);
|
||||||
|
return $snapshot;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
<?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 PostgresCatalogContractsTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $database;
|
||||||
|
private PDO $a;
|
||||||
|
private PDO $b;
|
||||||
|
private PdoCatalogRepository $first;
|
||||||
|
private PdoCatalogRepository $second;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
if (!RepositoryDatabase::postgresConfigured()) {
|
||||||
|
self::markTestSkipped('Requires isolated PostgreSQL.');
|
||||||
|
}
|
||||||
|
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-contracts-');
|
||||||
|
$this->a = RepositoryDatabase::migrate($this->database);
|
||||||
|
$this->b = RepositoryDatabase::connect($this->database);
|
||||||
|
foreach ([$this->a, $this->b] as $pdo) {
|
||||||
|
$pdo->exec("SET lock_timeout = '250ms'");
|
||||||
|
$pdo->exec("SET statement_timeout = '5s'");
|
||||||
|
}
|
||||||
|
self::assertNotSame(
|
||||||
|
$this->a->query('SELECT pg_backend_pid()')->fetchColumn(),
|
||||||
|
$this->b->query('SELECT pg_backend_pid()')->fetchColumn(),
|
||||||
|
);
|
||||||
|
self::assertSame(
|
||||||
|
$this->a->query('SELECT current_schema()')->fetchColumn(),
|
||||||
|
$this->b->query('SELECT current_schema()')->fetchColumn(),
|
||||||
|
);
|
||||||
|
$this->first = new PdoCatalogRepository($this->a);
|
||||||
|
$this->second = new PdoCatalogRepository($this->b);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach (['a', 'b'] as $property) {
|
||||||
|
if (isset($this->$property) && $this->$property->inTransaction()) {
|
||||||
|
$this->$property->rollBack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($this->first, $this->second, $this->a, $this->b);
|
||||||
|
if (isset($this->database)) {
|
||||||
|
RepositoryDatabase::cleanup($this->database);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function timeout(callable $operation): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$operation();
|
||||||
|
self::fail('Expected PostgreSQL lock timeout; a replay/no-op must not escape the lock.');
|
||||||
|
} catch (PDOException $error) {
|
||||||
|
self::assertSame('55P03', $error->getCode());
|
||||||
|
self::assertStringContainsString('lock timeout', $error->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function conflict(callable $operation, string $message): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$operation();
|
||||||
|
self::fail('Expected stale state rejection.');
|
||||||
|
} catch (DomainException $error) {
|
||||||
|
self::assertSame($message, $error->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function ownershipCases(): iterable
|
||||||
|
{
|
||||||
|
foreach (['assign', 'reassign', 'detach', 'noop', 'null-noop'] as $kind) {
|
||||||
|
foreach ([true, false] as $commit) {
|
||||||
|
yield $kind . ($commit ? '-commit' : '-rollback') => [$kind, $commit];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('ownershipCases')]
|
||||||
|
public function testRegistrationAgainstOwnershipAndReplayRetention(string $kind, bool $commit): void
|
||||||
|
{
|
||||||
|
$source = $this->first->createModel('/home', 'Source');
|
||||||
|
$target = $this->first->createModel('/home', 'Target');
|
||||||
|
$old = in_array($kind, ['assign', 'null-noop'], true) ? null : $source;
|
||||||
|
$next = match ($kind) {
|
||||||
|
'detach', 'null-noop' => null,
|
||||||
|
'noop' => $source,
|
||||||
|
default => $target,
|
||||||
|
};
|
||||||
|
$file = $this->first->recordFile($old, 'physical/nested/part.stl', 'hash', 1);
|
||||||
|
$this->a->beginTransaction();
|
||||||
|
$this->first->changeFileOwner([$file], $old, $next);
|
||||||
|
$this->timeout(fn () => $this->second->recordFile($old, 'physical/nested/part.stl', 'hash', 1));
|
||||||
|
$commit ? $this->a->commit() : $this->a->rollBack();
|
||||||
|
$current = $commit ? $next : $old;
|
||||||
|
if ($current !== $old) {
|
||||||
|
$this->conflict(
|
||||||
|
fn () => $this->second->recordFile($old, 'physical/nested/part.stl', 'hash', 1),
|
||||||
|
'Managed path already records a different file.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
$this->b->beginTransaction();
|
||||||
|
self::assertSame($file, $this->second->recordFile($current, 'physical/nested/part.stl', 'hash', 1));
|
||||||
|
$this->timeout(fn () => $this->first->changeFileOwner([$file], $current, $current));
|
||||||
|
$this->timeout(fn () => $this->first->createModel('/unrelated', 'Root retained'));
|
||||||
|
$this->b->commit();
|
||||||
|
$this->first->changeFileOwner([$file], $current, $current);
|
||||||
|
self::assertSame($current, $this->first->file($file)['model_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('ownershipCases')]
|
||||||
|
public function testMetadataKeyShareComposition(string $kind, bool $commit): void
|
||||||
|
{
|
||||||
|
// Reverse backend roles on rollback as well as exercising both transaction outcomes.
|
||||||
|
[$a, $b, $first, $second] = $commit
|
||||||
|
? [$this->a, $this->b, $this->first, $this->second]
|
||||||
|
: [$this->b, $this->a, $this->second, $this->first];
|
||||||
|
$model = $first->createModel('/home', 'Metadata target');
|
||||||
|
$source = $first->createModel('/home', 'Other owner');
|
||||||
|
$owner = match ($kind) {
|
||||||
|
'assign', 'null-noop' => null,
|
||||||
|
'noop', 'detach' => $model,
|
||||||
|
default => $source,
|
||||||
|
};
|
||||||
|
$target = in_array($kind, ['detach', 'null-noop'], true) ? null : $model;
|
||||||
|
$file = $first->recordFile($owner, 'physical/part.stl', 'hash', 1);
|
||||||
|
$old = $first->modelMetadata($model);
|
||||||
|
$new = array_replace($old, ['notes' => 'metadata']);
|
||||||
|
$a->beginTransaction();
|
||||||
|
$first->updateModelMetadata($model, $old, $new);
|
||||||
|
$b->beginTransaction();
|
||||||
|
$second->changeFileOwner([$file], $owner, $owner);
|
||||||
|
// B holds the file, A retains metadata after its operation savepoint times out.
|
||||||
|
$this->timeout(fn () => $first->changeFileOwner([$file], $owner, $target));
|
||||||
|
// Real FK acquisition for assign/reassign, not a simulated KEY SHARE query.
|
||||||
|
$second->changeFileOwner([$file], $owner, $target);
|
||||||
|
$this->timeout(fn () => $second->updateModelMetadata($model, $old, $old));
|
||||||
|
$commit ? $b->commit() : $b->rollBack();
|
||||||
|
$current = $commit ? $target : $owner;
|
||||||
|
$first->changeFileOwner([$file], $current, $target);
|
||||||
|
self::assertSame($new, $first->modelMetadata($model));
|
||||||
|
self::assertSame($target, $first->file($file)['model_id']);
|
||||||
|
$a->commit();
|
||||||
|
self::assertSame($new, $second->modelMetadata($model));
|
||||||
|
self::assertSame($target, $second->file($file)['model_id']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIndependentStatusOwnershipAndNoUnnecessaryRootLocks(): void
|
||||||
|
{
|
||||||
|
$model = $this->first->createModel('/home', 'Model');
|
||||||
|
$file = $this->first->recordFile($model, 'physical/part.stl', 'hash', 1);
|
||||||
|
$old = $this->first->modelMetadata($model);
|
||||||
|
$this->a->beginTransaction();
|
||||||
|
$this->a->exec("UPDATE folders SET name = name WHERE path = '/'");
|
||||||
|
$this->second->updateModelMetadata($model, $old, $old);
|
||||||
|
$this->second->changeFileStatus($file, 'Present', 'Missing');
|
||||||
|
$this->second->changeFileOwner([$file], $model, null);
|
||||||
|
$this->a->rollBack();
|
||||||
|
$this->b->beginTransaction();
|
||||||
|
$before = $this->second->file($file);
|
||||||
|
self::assertSame($before, $this->second->changeFileStatus($file, 'Missing', 'Missing'));
|
||||||
|
$this->timeout(fn () => $this->first->changeFileOwner([$file], null, $model));
|
||||||
|
$this->b->commit();
|
||||||
|
$this->first->changeFileOwner([$file], null, $model);
|
||||||
|
self::assertSame($file, $this->first->recordFile($model, 'physical/part.stl', 'hash', 1));
|
||||||
|
self::assertSame('Missing', $this->first->file($file)['status']);
|
||||||
|
self::assertSame($model, $this->first->file($file)['model_id']);
|
||||||
|
self::assertSame($before['updated_at'], $this->first->file($file)['updated_at']);
|
||||||
|
$this->b->beginTransaction();
|
||||||
|
$this->second->updateModelMetadata($model, $old, $old);
|
||||||
|
$this->timeout(fn () => $this->first->updateModelMetadata($model, $old, $old));
|
||||||
|
$this->b->rollBack();
|
||||||
|
self::assertSame($old, $this->first->updateModelMetadata($model, $old, $old));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('outcomes')]
|
||||||
|
public function testNewPathSerializationAndPhysicalFolders(bool $commit): void
|
||||||
|
{
|
||||||
|
$model = $this->first->createModel('/organizational', 'Model');
|
||||||
|
$this->a->beginTransaction();
|
||||||
|
$file = $this->first->recordFile($model, 'physical/nested/part.stl', 'hash', 1);
|
||||||
|
$this->timeout(fn () => $this->second->recordFile(null, 'physical/nested/part.stl', 'hash', 1));
|
||||||
|
self::assertSame(0, (int) $this->b->query('SELECT COUNT(*) FROM files')->fetchColumn());
|
||||||
|
$commit ? $this->a->commit() : $this->a->rollBack();
|
||||||
|
if ($commit) {
|
||||||
|
$this->conflict(
|
||||||
|
fn () => $this->second->recordFile(null, 'physical/nested/part.stl', 'hash', 1),
|
||||||
|
'Managed path already records a different file.',
|
||||||
|
);
|
||||||
|
self::assertSame($file, $this->second->recordFile($model, 'physical/nested/part.stl', 'hash', 1));
|
||||||
|
} else {
|
||||||
|
self::assertSame(0, (int) $this->b->query(
|
||||||
|
"SELECT COUNT(*) FROM folders WHERE path LIKE '/physical%'",
|
||||||
|
)->fetchColumn());
|
||||||
|
$file = $this->second->recordFile(null, 'physical/nested/part.stl', 'hash', 1);
|
||||||
|
}
|
||||||
|
self::assertSame(1, (int) $this->b->query('SELECT COUNT(*) FROM files')->fetchColumn());
|
||||||
|
$folder = $this->second->file($file)['folder_id'];
|
||||||
|
self::assertSame('/physical/nested', $this->b->query(
|
||||||
|
'SELECT path FROM folders WHERE id = ' . $folder,
|
||||||
|
)->fetchColumn());
|
||||||
|
self::assertNotSame((int) $this->second->aggregate($model)['model']['folder_id'], $folder);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRegistrationTraceAndSqlFailureRollback(): void
|
||||||
|
{
|
||||||
|
$model = $this->first->createModel('/home', 'Model');
|
||||||
|
$trace = new \ArrayObject();
|
||||||
|
$this->a->setAttribute(PDO::ATTR_STATEMENT_CLASS, [CatalogContractsStatement::class, [$trace]]);
|
||||||
|
$file = $this->first->recordFile($model, 'physical/nested/part.stl', 'hash', 1);
|
||||||
|
$sql = $trace->getArrayCopy();
|
||||||
|
$insert = array_key_last($sql);
|
||||||
|
self::assertStringContainsString('INSERT INTO files', $sql[$insert]);
|
||||||
|
self::assertStringContainsString('SELECT id FROM folders', $sql[$insert - 1]);
|
||||||
|
foreach ([false, true] as $conflict) {
|
||||||
|
$trace->exchangeArray([]);
|
||||||
|
if ($conflict) {
|
||||||
|
$this->conflict(
|
||||||
|
fn () => $this->first->recordFile(null, 'physical/nested/part.stl', 'hash', 1),
|
||||||
|
'Managed path already records a different file.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
self::assertSame($file, $this->first->recordFile($model, 'physical/nested/part.stl', 'hash', 1));
|
||||||
|
}
|
||||||
|
$sql = $trace->getArrayCopy();
|
||||||
|
self::assertCount(4, $sql);
|
||||||
|
self::assertSame("UPDATE folders SET name = name WHERE path = '/'", $sql[2]);
|
||||||
|
self::assertSame('SELECT * FROM files WHERE managed_path = ? FOR UPDATE', $sql[3]);
|
||||||
|
}
|
||||||
|
$this->a->exec("ALTER TABLE files ADD CONSTRAINT reject_test_hash CHECK (hash <> 'rejected')");
|
||||||
|
$this->a->beginTransaction();
|
||||||
|
$this->a->exec("INSERT INTO tags (name) VALUES ('sentinel')");
|
||||||
|
try {
|
||||||
|
$this->first->recordFile(null, 'rollback/nested/part.stl', 'rejected', 1);
|
||||||
|
self::fail('Expected actual SQL constraint failure.');
|
||||||
|
} catch (PDOException $error) {
|
||||||
|
self::assertSame('23514', $error->getCode());
|
||||||
|
}
|
||||||
|
self::assertTrue($this->a->inTransaction());
|
||||||
|
self::assertSame('sentinel', $this->a->query('SELECT name FROM tags')->fetchColumn());
|
||||||
|
self::assertSame(0, (int) $this->a->query(
|
||||||
|
"SELECT COUNT(*) FROM folders WHERE path LIKE '/rollback%'",
|
||||||
|
)->fetchColumn());
|
||||||
|
// The operation's root lock was released, while earlier caller work remains uncommitted.
|
||||||
|
$this->second->createModel('/after-failure', 'Independent');
|
||||||
|
self::assertSame(0, (int) $this->b->query('SELECT COUNT(*) FROM tags')->fetchColumn());
|
||||||
|
$this->a->commit();
|
||||||
|
self::assertSame('sentinel', $this->b->query('SELECT name FROM tags')->fetchColumn());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function outcomes(): array
|
||||||
|
{
|
||||||
|
return ['commit' => [true], 'rollback' => [false]];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('outcomes')]
|
||||||
|
public function testMetadataAndStatusStaleEditsIncludingEqualTarget(bool $commit): void
|
||||||
|
{
|
||||||
|
$model = $this->first->createModel('/home', 'Original');
|
||||||
|
$file = $this->first->recordFile($model, 'physical/part.stl', 'hash', 1);
|
||||||
|
$old = $this->first->modelMetadata($model);
|
||||||
|
$new = array_replace($old, ['notes' => 'winner']);
|
||||||
|
$this->a->beginTransaction();
|
||||||
|
$this->first->updateModelMetadata($model, $old, $new);
|
||||||
|
$this->first->changeFileStatus($file, 'Present', 'Missing');
|
||||||
|
$this->b->beginTransaction();
|
||||||
|
$this->b->exec("INSERT INTO tags (name) VALUES ('caller survives')");
|
||||||
|
foreach ([$old, $new] as $replacement) {
|
||||||
|
$this->timeout(fn () => $this->second->updateModelMetadata($model, $old, $replacement));
|
||||||
|
}
|
||||||
|
foreach (['Present', 'Missing'] as $target) {
|
||||||
|
$this->timeout(fn () => $this->second->changeFileStatus($file, 'Present', $target));
|
||||||
|
}
|
||||||
|
self::assertTrue($this->b->inTransaction());
|
||||||
|
self::assertSame('caller survives', $this->b->query('SELECT name FROM tags')->fetchColumn());
|
||||||
|
$commit ? $this->a->commit() : $this->a->rollBack();
|
||||||
|
if ($commit) {
|
||||||
|
$this->conflict(
|
||||||
|
fn () => $this->second->updateModelMetadata($model, $old, $new),
|
||||||
|
'Model metadata has changed.',
|
||||||
|
);
|
||||||
|
$this->conflict(
|
||||||
|
fn () => $this->second->changeFileStatus($file, 'Present', 'Missing'),
|
||||||
|
'File status has changed.',
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
self::assertSame($new, $this->second->updateModelMetadata($model, $old, $new));
|
||||||
|
self::assertSame('Missing', $this->second->changeFileStatus($file, 'Present', 'Missing')['status']);
|
||||||
|
}
|
||||||
|
self::assertTrue($this->b->inTransaction());
|
||||||
|
$this->b->rollBack();
|
||||||
|
self::assertSame($commit ? $new : $old, $this->first->modelMetadata($model));
|
||||||
|
self::assertSame($commit ? 'Missing' : 'Present', $this->first->file($file)['status']);
|
||||||
|
self::assertSame(0, (int) $this->a->query('SELECT COUNT(*) FROM tags')->fetchColumn());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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]];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Tests\Integration;
|
||||||
|
|
||||||
|
use MeshVault\Repository\PdoImportJobRepository;
|
||||||
|
use PDO;
|
||||||
|
use PDOException;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/** Opt in with MESHVAULT_TEST_PG_DSN (and optional PG_USER / PG_PASSWORD with the same prefix). */
|
||||||
|
final class PostgresRepositoryTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $database;
|
||||||
|
private PDO $pdo;
|
||||||
|
private PDO $other;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
if (!RepositoryDatabase::postgresConfigured()) {
|
||||||
|
self::markTestSkipped('Set MESHVAULT_TEST_PG_DSN to run PostgreSQL concurrency tests.');
|
||||||
|
}
|
||||||
|
// Configured connection/migration failures must be errors, never skips.
|
||||||
|
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-pg-');
|
||||||
|
$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'");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach (['pdo', 'other'] as $property) {
|
||||||
|
if (isset($this->$property) && $this->$property->inTransaction()) {
|
||||||
|
$this->$property->rollBack();
|
||||||
|
}
|
||||||
|
unset($this->$property);
|
||||||
|
}
|
||||||
|
if (isset($this->database)) {
|
||||||
|
RepositoryDatabase::cleanup($this->database);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMigrationsAndCleanupAreIsolatedToTheGeneratedSchema(): void
|
||||||
|
{
|
||||||
|
$path = tempnam(sys_get_temp_dir(), 'meshvault-pg-isolated-');
|
||||||
|
try {
|
||||||
|
$isolated = RepositoryDatabase::migrate($path);
|
||||||
|
$schema = $isolated->query('SELECT current_schema()')->fetchColumn();
|
||||||
|
$original = $this->pdo->query('SELECT current_schema()')->fetchColumn();
|
||||||
|
self::assertMatchesRegularExpression('/^meshvault_test_[a-f0-9]{32}$/', $schema);
|
||||||
|
self::assertNotSame($original, $schema);
|
||||||
|
self::assertSame($original, $this->other->query('SELECT current_schema()')->fetchColumn());
|
||||||
|
self::assertNotSame(
|
||||||
|
$this->pdo->query('SELECT pg_backend_pid()')->fetchColumn(),
|
||||||
|
$this->other->query('SELECT pg_backend_pid()')->fetchColumn(),
|
||||||
|
);
|
||||||
|
$jobs = new PdoImportJobRepository($this->pdo);
|
||||||
|
$job = $jobs->create('/survives-cleanup', []);
|
||||||
|
self::assertSame(0, (int) $isolated->query('SELECT COUNT(*) FROM import_jobs')->fetchColumn());
|
||||||
|
unset($isolated);
|
||||||
|
RepositoryDatabase::cleanup($path);
|
||||||
|
$exists = $this->pdo->prepare('SELECT COUNT(*) FROM pg_namespace WHERE nspname = ?');
|
||||||
|
$exists->execute([$schema]);
|
||||||
|
self::assertSame(0, (int) $exists->fetchColumn());
|
||||||
|
self::assertSame('/survives-cleanup', $jobs->get($job)['source_path']);
|
||||||
|
// An unregistered path is never interpreted as a schema identifier.
|
||||||
|
RepositoryDatabase::cleanup($schema);
|
||||||
|
} finally {
|
||||||
|
unset($isolated);
|
||||||
|
RepositoryDatabase::cleanup($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOverlappingClaimsSkipLockedJobsAndReturnNullWhenAllAreLocked(): void
|
||||||
|
{
|
||||||
|
$jobs = new PdoImportJobRepository($this->pdo);
|
||||||
|
$other = new PdoImportJobRepository($this->other);
|
||||||
|
$first = $jobs->create('/first', []);
|
||||||
|
$second = $jobs->create('/second', []);
|
||||||
|
|
||||||
|
// The outer transaction must retain claimNext's row lock after it returns.
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
self::assertSame($first, (int) $jobs->claimNext()['id']);
|
||||||
|
self::assertTrue($this->pdo->inTransaction());
|
||||||
|
self::assertSame('Pending', $other->get($first)['status']);
|
||||||
|
self::assertSame($second, (int) $other->claimNext()['id']);
|
||||||
|
self::assertNull($other->claimNext());
|
||||||
|
self::assertTrue($this->pdo->inTransaction());
|
||||||
|
$this->pdo->commit();
|
||||||
|
self::assertSame('Copying', $other->get($first)['status']);
|
||||||
|
self::assertSame('Copying', $other->get($second)['status']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRolledBackClaimCanBeReclaimedByTheOtherConnection(): void
|
||||||
|
{
|
||||||
|
$jobs = new PdoImportJobRepository($this->pdo);
|
||||||
|
$other = new PdoImportJobRepository($this->other);
|
||||||
|
$job = $jobs->create('/rollback', []);
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
self::assertSame($job, (int) $jobs->claimNext()['id']);
|
||||||
|
self::assertNull($other->claimNext());
|
||||||
|
$this->pdo->rollBack();
|
||||||
|
self::assertSame('Pending', $other->get($job)['status']);
|
||||||
|
self::assertNull($other->get($job)['started_at']);
|
||||||
|
self::assertSame($job, (int) $other->claimNext()['id']);
|
||||||
|
self::assertNull($jobs->claimNext());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTerminalNoOpStillAcquiresExplicitItemReadLock(): void
|
||||||
|
{
|
||||||
|
$jobs = new PdoImportJobRepository($this->pdo);
|
||||||
|
$other = new PdoImportJobRepository($this->other);
|
||||||
|
$job = $jobs->create('/terminal', [['source_path' => '/terminal/a', 'size' => 7]]);
|
||||||
|
$item = (int) $jobs->items($job)[0]['id'];
|
||||||
|
$jobs->finishItem($item, 'Completed');
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
$statement = $this->pdo->prepare('SELECT id FROM import_items WHERE id = ? FOR SHARE');
|
||||||
|
$statement->execute([$item]);
|
||||||
|
$statement->closeCursor();
|
||||||
|
try {
|
||||||
|
// Already terminal: no UPDATE occurs, so only SELECT FOR UPDATE can block.
|
||||||
|
$other->finishItem($item, 'Completed');
|
||||||
|
self::fail('The explicit item read lock must conflict with FOR SHARE.');
|
||||||
|
} catch (PDOException $error) {
|
||||||
|
self::assertSame('55P03', $error->getCode());
|
||||||
|
}
|
||||||
|
self::assertFalse($this->other->inTransaction());
|
||||||
|
$this->pdo->commit();
|
||||||
|
$other->finishItem($item, 'Completed');
|
||||||
|
self::assertSame(1, (int) $jobs->get($job)['processed_files']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testItemLockTimesOutWithoutPublishingProgressAndCanBeRetried(): void
|
||||||
|
{
|
||||||
|
$jobs = new PdoImportJobRepository($this->pdo);
|
||||||
|
$other = new PdoImportJobRepository($this->other);
|
||||||
|
$job = $jobs->create('/item', [['source_path' => '/item/a', 'size' => 7]]);
|
||||||
|
$jobs->claimNext();
|
||||||
|
$item = (int) $jobs->items($job)[0]['id'];
|
||||||
|
$this->pdo->beginTransaction();
|
||||||
|
$jobs->planItem($item, 'a.stl', str_repeat('a', 64), 7);
|
||||||
|
self::assertTrue($this->pdo->inTransaction());
|
||||||
|
try {
|
||||||
|
$other->finishItem($item, 'Completed');
|
||||||
|
self::fail('A concurrent item transition must wait for the item lock.');
|
||||||
|
} catch (PDOException $error) {
|
||||||
|
self::assertSame('55P03', $error->getCode(), 'Expected PostgreSQL lock_not_available.');
|
||||||
|
}
|
||||||
|
self::assertFalse($this->other->inTransaction());
|
||||||
|
self::assertSame('Pending', $other->items($job)[0]['status']);
|
||||||
|
self::assertSame(0, (int) $other->get($job)['processed_files']);
|
||||||
|
$this->pdo->commit();
|
||||||
|
$other->finishItem($item, 'Completed');
|
||||||
|
self::assertSame('Completed', $jobs->items($job)[0]['status']);
|
||||||
|
self::assertSame(1, (int) $jobs->get($job)['processed_files']);
|
||||||
|
self::assertSame(7, (int) $jobs->get($job)['copied_bytes']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,27 +10,143 @@ use Phinx\Migration\Manager;
|
|||||||
use Symfony\Component\Console\Input\ArrayInput;
|
use Symfony\Component\Console\Input\ArrayInput;
|
||||||
use Symfony\Component\Console\Output\NullOutput;
|
use Symfony\Component\Console\Output\NullOutput;
|
||||||
|
|
||||||
/** Runs the production migrations against SQLite and manages scratch directories. */
|
/** Runs production migrations against SQLite, or an explicitly configured PostgreSQL database. */
|
||||||
final class RepositoryDatabase
|
final class RepositoryDatabase
|
||||||
{
|
{
|
||||||
/** Never a hand-maintained test schema: drift must fail the suite. */
|
/** @var array<string, array{schema: string, connection: PDO}> */
|
||||||
public static function migrate(string $path): 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');
|
||||||
|
return $dsn !== false && $dsn !== '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Never a hand-maintained test schema: drift must fail the suite. */
|
||||||
|
public static function migrate(string $path, ?int $target = null): PDO
|
||||||
|
{
|
||||||
|
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'];
|
||||||
|
if (self::postgresConfigured()) {
|
||||||
|
$pdo = self::postgresConnection();
|
||||||
|
$schema = 'meshvault_test_' . bin2hex(random_bytes(16));
|
||||||
|
$pdo->exec('CREATE SCHEMA "' . $schema . '"');
|
||||||
|
self::$schemas[$path] = ['schema' => $schema, 'connection' => $pdo];
|
||||||
|
$pdo->exec('SET search_path TO "' . $schema . '"');
|
||||||
|
$environment = ['adapter' => 'pgsql', 'schema' => $schema];
|
||||||
|
} else {
|
||||||
|
$pdo = self::connect($path);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$config = new Config([
|
||||||
|
'paths' => ['migrations' => dirname(__DIR__, 2) . '/db/migrations'],
|
||||||
|
'environments' => [
|
||||||
|
'default_migration_table' => 'phinxlog',
|
||||||
|
'default_database' => 'test',
|
||||||
|
'test' => $environment + ['connection' => $pdo],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
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'] . '"');
|
||||||
|
}
|
||||||
|
return $pdo;
|
||||||
|
} catch (\Throwable $error) {
|
||||||
|
self::cleanup($path);
|
||||||
|
throw $error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function connect(string $path): PDO
|
||||||
|
{
|
||||||
|
if (isset(self::$schemas[$path])) {
|
||||||
|
$pdo = self::postgresConnection();
|
||||||
|
$pdo->exec('SET search_path TO "' . self::$schemas[$path]['schema'] . '"');
|
||||||
|
return $pdo;
|
||||||
|
}
|
||||||
|
if (self::postgresConfigured()) {
|
||||||
|
throw new \LogicException('Migrate this path before connecting to PostgreSQL.');
|
||||||
|
}
|
||||||
$pdo = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
$pdo = new PDO('sqlite:' . $path, null, null, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]);
|
||||||
$config = new Config([
|
|
||||||
'paths' => ['migrations' => dirname(__DIR__, 2) . '/db/migrations'],
|
|
||||||
'environments' => [
|
|
||||||
'default_migration_table' => 'phinxlog',
|
|
||||||
'default_database' => 'test',
|
|
||||||
'test' => ['adapter' => 'sqlite', 'connection' => $pdo],
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
(new Manager($config, new ArrayInput([]), new NullOutput()))->migrate('test');
|
|
||||||
$pdo->exec('PRAGMA foreign_keys = ON');
|
$pdo->exec('PRAGMA foreign_keys = ON');
|
||||||
$pdo->exec('PRAGMA busy_timeout = 5000');
|
$pdo->exec('PRAGMA busy_timeout = 5000');
|
||||||
return $pdo;
|
return $pdo;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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()) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
}
|
||||||
|
// Only identifiers generated and registered by migrate() can reach this statement.
|
||||||
|
$pdo->exec('DROP SCHEMA "' . $schema . '" CASCADE');
|
||||||
|
unset(self::$schemas[$path]);
|
||||||
|
}
|
||||||
|
if (is_file($path)) {
|
||||||
|
unlink($path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function postgresConnection(): PDO
|
||||||
|
{
|
||||||
|
$dsn = (string) getenv('MESHVAULT_TEST_PG_DSN');
|
||||||
|
if (!str_starts_with($dsn, 'pgsql:')) {
|
||||||
|
throw new \InvalidArgumentException('MESHVAULT_TEST_PG_DSN must be a pgsql: DSN.');
|
||||||
|
}
|
||||||
|
return new PDO(
|
||||||
|
$dsn,
|
||||||
|
($user = getenv('MESHVAULT_TEST_PG_USER')) === false ? null : $user,
|
||||||
|
($password = getenv('MESHVAULT_TEST_PG_PASSWORD')) === false ? null : $password,
|
||||||
|
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public static function directory(string $prefix): string
|
public static function directory(string $prefix): string
|
||||||
{
|
{
|
||||||
$path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'meshvault-' . $prefix . '-' . bin2hex(random_bytes(6));
|
$path = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'meshvault-' . $prefix . '-' . bin2hex(random_bytes(6));
|
||||||
|
|||||||
@@ -27,7 +27,15 @@ final class RepositoryTest extends TestCase
|
|||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
{
|
{
|
||||||
unset($this->pdo);
|
unset($this->pdo);
|
||||||
unlink($this->database);
|
RepositoryDatabase::cleanup($this->database);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSecondConnectionUsesTheMigratedDatabase(): void
|
||||||
|
{
|
||||||
|
$jobs = new PdoImportJobRepository($this->pdo);
|
||||||
|
$job = $jobs->create('/shared', []);
|
||||||
|
$other = new PdoImportJobRepository(RepositoryDatabase::connect($this->database));
|
||||||
|
self::assertSame('/shared', $other->get($job)['source_path']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testCatalogGroupingAndFileLifecycle(): void
|
public function testCatalogGroupingAndFileLifecycle(): void
|
||||||
@@ -44,7 +52,7 @@ final class RepositoryTest extends TestCase
|
|||||||
$file = $catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('a', 64), 12, 'model/stl');
|
$file = $catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('a', 64), 12, 'model/stl');
|
||||||
self::assertSame($file, $catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('a', 64), 12));
|
self::assertSame($file, $catalog->recordFile($model, 'vehicles/aircraft/wing.stl', str_repeat('a', 64), 12));
|
||||||
foreach (['Missing', 'Updated', 'Duplicate', 'Present'] as $status) {
|
foreach (['Missing', 'Updated', 'Duplicate', 'Present'] as $status) {
|
||||||
$catalog->setFileStatus($file, $status);
|
$catalog->changeFileStatus($file, $catalog->file($file)['status'], $status);
|
||||||
self::assertSame($status, $catalog->aggregate($model)['files'][0]['status']);
|
self::assertSame($status, $catalog->aggregate($model)['files'][0]['status']);
|
||||||
}
|
}
|
||||||
$aggregate = $catalog->aggregate($model);
|
$aggregate = $catalog->aggregate($model);
|
||||||
@@ -126,7 +134,7 @@ final class RepositoryTest extends TestCase
|
|||||||
$catalog = new PdoCatalogRepository($this->pdo);
|
$catalog = new PdoCatalogRepository($this->pdo);
|
||||||
$job = $jobs->create('/', [['source_path' => '/a', 'size' => 5, 'model_id' => null]]);
|
$job = $jobs->create('/', [['source_path' => '/a', 'size' => 5, 'model_id' => null]]);
|
||||||
$second = $jobs->create('/', []);
|
$second = $jobs->create('/', []);
|
||||||
$other = new PdoImportJobRepository(new PDO('sqlite:' . $this->database));
|
$other = new PdoImportJobRepository(RepositoryDatabase::connect($this->database));
|
||||||
self::assertSame($job, (int) $jobs->claimNext()['id']);
|
self::assertSame($job, (int) $jobs->claimNext()['id']);
|
||||||
self::assertSame($second, (int) $other->claimNext()['id']);
|
self::assertSame($second, (int) $other->claimNext()['id']);
|
||||||
self::assertNull($other->claimNext());
|
self::assertNull($other->claimNext());
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace MeshVault\Tests\Unit\Catalog;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use MeshVault\Catalog\ModelMetadata;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ModelMetadataTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testContractExists(): void
|
||||||
|
{
|
||||||
|
self::assertTrue(class_exists(ModelMetadata::class));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('invalidTitles')]
|
||||||
|
public function testInvalidTitles(string $title): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
ModelMetadata::validateTitle($title);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function invalidTitles(): array
|
||||||
|
{
|
||||||
|
return [[''], [" \t\n"], ["a\0b"], ["\xff"], [str_repeat('é', 256)]];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPreservesValidAndUnchangedLegacyValues(): void
|
||||||
|
{
|
||||||
|
$old = ['title' => '', 'notes' => "\xff", 'sourceURL' => 'not a URL'];
|
||||||
|
self::assertSame($old, ModelMetadata::replacement($old, $old));
|
||||||
|
$new = ['title' => ' ' . str_repeat('é', 253) . ' ', 'notes' => "\xff", 'sourceURL' => 'not a URL'];
|
||||||
|
self::assertSame($new, ModelMetadata::replacement($old, $new));
|
||||||
|
$old = ['title' => 'T', 'notes' => 'old', 'sourceURL' => 'old'];
|
||||||
|
self::assertSame(['title' => 'T', 'notes' => null, 'sourceURL' => null], ModelMetadata::replacement(
|
||||||
|
$old,
|
||||||
|
['title' => 'T', 'notes' => '', 'sourceURL' => '']
|
||||||
|
));
|
||||||
|
ModelMetadata::validateTitle('é');
|
||||||
|
self::assertSame(" \n", ModelMetadata::replacement($old, array_replace($old, ['notes' => " \n"]))['notes']);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('urls')]
|
||||||
|
public function testUrls(string $url, bool $valid): void
|
||||||
|
{
|
||||||
|
$old = ['title' => 'T', 'notes' => null, 'sourceURL' => null];
|
||||||
|
if (!$valid) {
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
}
|
||||||
|
$result = ModelMetadata::replacement($old, array_replace($old, ['sourceURL' => $url]));
|
||||||
|
self::assertSame($url, $result['sourceURL']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function urls(): array
|
||||||
|
{
|
||||||
|
$valid = ['https://example.com/a?q=x#f', 'http://127.0.0.1:80/', 'https://[::1]/',
|
||||||
|
'https://xn--bcher-kva.example/%C3%A9', 'https://example.com/' . str_repeat('a', 2028)];
|
||||||
|
$invalid = ['relative', 'ftp://example.com', 'https://', 'https://u:p@example.com',
|
||||||
|
'https://u@example.com', "https://example.com/\n", 'https://example.com/a b',
|
||||||
|
'https://example.com/\\x', 'https://example.com/%zz', 'https://example.com/%',
|
||||||
|
'https://example.com/é', 'http://example.com:65536', 'http://example.com:abc',
|
||||||
|
'https://example.com/' . str_repeat('a', 2030)];
|
||||||
|
return array_merge(
|
||||||
|
array_map(static fn ($v) => [$v, true], $valid),
|
||||||
|
array_map(static fn ($v) => [$v, false], $invalid)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('badShapes')]
|
||||||
|
public function testExactShape(array $shape): void
|
||||||
|
{
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
ModelMetadata::validateShape($shape);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function badShapes(): array
|
||||||
|
{
|
||||||
|
return [[[]], [['title' => 'T', 'notes' => null]],
|
||||||
|
[['title' => null, 'notes' => null, 'sourceURL' => null]],
|
||||||
|
[['title' => 'T', 'notes' => 1, 'sourceURL' => null]],
|
||||||
|
[['title' => 'T', 'notes' => null, 'sourceURL' => false]],
|
||||||
|
[['title' => 'T', 'notes' => null, 'sourceURL' => null, 'extra' => null]]];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('badNotes')]
|
||||||
|
public function testChangedNotesMustBePortable(string $notes): void
|
||||||
|
{
|
||||||
|
$old = ['title' => 'T', 'notes' => null, 'sourceURL' => null];
|
||||||
|
$this->expectException(InvalidArgumentException::class);
|
||||||
|
ModelMetadata::replacement($old, array_replace($old, ['notes' => $notes]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function badNotes(): array
|
||||||
|
{
|
||||||
|
return [["\xff"], ["a\0b"]];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,38 @@ final class ContainerWiringTest extends TestCase
|
|||||||
$this->pdo->exec('PRAGMA foreign_keys = ON');
|
$this->pdo->exec('PRAGMA foreign_keys = ON');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testActualFactoryInvocationUsesSharedServiceAndStopCallback(): void
|
||||||
|
{
|
||||||
|
$config = AppConfig::fromEnvironment([
|
||||||
|
'MESHVAULT_LIBRARY_DIR' => dirname(__DIR__, 3), 'MESHVAULT_DB_HOST' => 'unused',
|
||||||
|
'MESHVAULT_DB_NAME' => 'unused', 'MESHVAULT_DB_USER' => 'unused',
|
||||||
|
'MESHVAULT_DB_PASSWORD' => 'unused',
|
||||||
|
]);
|
||||||
|
$definitions = require dirname(__DIR__, 3) . '/config/container.php';
|
||||||
|
$builder = new \DI\ContainerBuilder();
|
||||||
|
$builder->useAutowiring(false);
|
||||||
|
$builder->addDefinitions($definitions($config));
|
||||||
|
$builder->addDefinitions([
|
||||||
|
PDO::class => $this->pdo,
|
||||||
|
\Psr\Log\LoggerInterface::class => new \Psr\Log\NullLogger(),
|
||||||
|
]);
|
||||||
|
$container = $builder->build();
|
||||||
|
$stop = static fn (): bool => true;
|
||||||
|
$worker = ($container->get(\MeshVault\Console\ImportWorkerFactory::class))($stop);
|
||||||
|
$service = $container->get(\MeshVault\Catalog\CatalogServiceInterface::class);
|
||||||
|
self::assertSame($container->get(\MeshVault\Catalog\CatalogService::class), $service);
|
||||||
|
$reflection = new \ReflectionObject($worker);
|
||||||
|
self::assertSame($service, $reflection->getProperty('catalog')->getValue($worker));
|
||||||
|
self::assertSame($stop, $reflection->getProperty('shouldStop')->getValue($worker));
|
||||||
|
foreach ([CatalogRepositoryInterface::class, ImportJobRepositoryInterface::class] as $class) {
|
||||||
|
$repository = $container->get($class);
|
||||||
|
$property = (new \ReflectionObject($repository))->getProperty('pdo');
|
||||||
|
self::assertSame($this->pdo, $property->getValue($repository));
|
||||||
|
}
|
||||||
|
$import = $container->get(\MeshVault\Indexer\ImportService::class);
|
||||||
|
self::assertInstanceOf(\MeshVault\Indexer\ImportService::class, $import);
|
||||||
|
}
|
||||||
|
|
||||||
public function testRepositoriesShareSamePdoInstance(): void
|
public function testRepositoriesShareSamePdoInstance(): void
|
||||||
{
|
{
|
||||||
// L5: assert that the container would wire both repositories to the SAME PDO
|
// L5: assert that the container would wire both repositories to the SAME PDO
|
||||||
|
|||||||
Reference in new issue
Block a user