Compare commits

...
Author SHA1 Message Date
Cline 99f19e1052 sibling explicit-grouping implementation: commit all work, removal of stray scratch files; update docs and status; keep P1 fix and other senior details 2026-10-18 19:41:29 -05:00
Cline 9dd8c07e66 docs: add issue/evidence reconciliation table (2026-10-14) 2026-10-13 20:22:15 -05:00
Cline 4d10f4fb2b docs: update project-status with merge and issue closure (2026-10-14) 2026-10-13 20:14:18 -05:00
Cline 46d347b2d2 docs: update project-status with integration status (2026-10-14) 2026-10-13 19:45:48 -05:00
Cline a829d6e8c6 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.
2026-10-13 19:41:03 -05:00
Cline 6b9fb54b4f feat: establish catalog identity and atomic file ownership 2026-10-12 19:38:38 -05:00
Cline 390a7a557a docs: reconcile milestone acceptance and propose catalog core design 2026-10-12 13:58:24 -05:00
Cline c1ef2ee97a test: validate M3 imports and locking on PostgreSQL
Add isolated PostgreSQL fixtures, two-session locking coverage, and a disposable remote Docker test runner. Correct the canonical-root fixture and document verified SQLite and PostgreSQL results.
2026-10-12 13:19:10 -05:00
Cline 9d277d9e72 feat: M3 import & indexing engine delivery layer with durable collision handling, space checking and console 2026-10-12 11:29:17 -05:00
Keith Solomon 93d991703c feat: M2 database migration runner with Phinx and initial schema 2026-10-11 18:05:23 -05:00
Keith Solomon d3e8cf8fae build: M1 Dockerized development stack with PHP skeleton, config/logging, Tailwind assets, and wrappers
Implements MeshVault M1 (issues #6–11):

- Four-service Docker Compose stack (nginx, PHP-FPM 8.3, PostgreSQL 17, Node assets)
- Slim 4 + PHP-DI routing with escaped PHP views and production/dev error handling
- Immutable validated AppConfig with redacted diagnostics
- JSON stdout Monolog logging with job-scoped context
- Tailwind CSS v4 deterministic locked builds with template scanning
- POSIX and PowerShell dev wrappers (up/down/test/lint/assets/watch)
- Startup config validation fails before FPM serves as unprivileged www-data
- migrate/seed explicitly unavailable until M2 (#12); #10 remains open

Verified via clean-start acceptance on remote host (keith@192.168.2.23).
2026-10-11 16:45:45 -05:00
126 changed files with 17425 additions and 38 deletions

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
# Explicit build inputs only: exclude secrets, collections and host dependencies.
**
!docker/
!docker/php/
!docker/php/**
!composer.json
!composer.lock
!bin/
!bin/**
!docker/assets/
!docker/assets/**
!package.json
!package-lock.json
+30
View File
@@ -0,0 +1,30 @@
# Copy to ignored .env and choose a private password before starting.
COMPOSE_PROJECT_NAME=meshvault-m1
MESHVAULT_HTTP_HOST=127.0.0.1
MESHVAULT_HTTP_PORT=8090
MESHVAULT_DB_NAME=meshvault
MESHVAULT_DB_USER=meshvault
MESHVAULT_DB_PASSWORD=
# Dedicated managed destination only, never an original collection.
# Host directory must already exist and be writable by container UID/GID 33.
MESHVAULT_LIBRARY_HOST_DIR=./library
# Absolute in-container path, outside /var/www/html; keep both paths distinct.
MESHVAULT_LIBRARY_DIR=/var/lib/meshvault/library
# Application configuration (Task2 library; startup/Compose wiring is Task3).
# Native PHP does NOT load this file: export variables in the shell or pass an
# explicit string array to AppConfig::fromEnvironment(). Never commit secrets.
# Defaults apply only when a key is absent; explicitly empty values are invalid.
# Allowed: development, test, production. Default: production.
MESHVAULT_ENV=development
# PSR-3: debug, info, notice, warning, error, critical, alert, emergency. Default: info.
MESHVAULT_LOG_LEVEL=info
# Required DB_HOST/DB_NAME/DB_USER/DB_PASSWORD and LIBRARY_DIR have no defaults.
# DB host/name/user must have no controls or surrounding whitespace.
# Password is preserved verbatim; blank/whitespace-only or NUL is invalid.
MESHVAULT_DB_HOST=db
# Decimal digits 1..65535; default: 5432.
MESHVAULT_DB_PORT=5432
# LIBRARY_DIR must be absolute, existing and writable by the actual runtime user.
# Validation never creates/chmods directories or opens a database connection.
+5
View File
@@ -0,0 +1,5 @@
# Enforce LF line endings for all text files to avoid container CRLF issues.
* text=auto eol=lf
# Ensure shell entrypoints remain executable across Windows checkouts.
docker/php/*.sh text eol=lf
+27 -12
View File
@@ -1,8 +1,26 @@
# Contributing to MeshVault
# Contributing to MeshVault
## Setup and checks
Use PHP 8.3+ and Composer 2. Run from the repository root:
Host requirements: Docker Compose v2+ with Linux containers. No host PHP,
Composer or Node is required.
```sh
# Start the stack (builds images if needed)
./dev.sh up
# Or on Windows: .\dev.ps1 up
# Run checks inside containers
./dev.sh test
./dev.sh lint
# Build assets deterministically
./dev.sh assets
# Watch during development
./dev.sh watch
```
If you have PHP 8.3+ and Composer 2 locally, you can also run:
```sh
composer install --no-interaction
@@ -11,17 +29,14 @@ composer lint
composer test
```
Commit `composer.lock` for reproducible development tooling. PHP_CodeSniffer is a
development dependency, not an application framework. Composer scripts use PHP and
local vendor binaries so they work in PowerShell as well as Unix shells.
Commit both `composer.lock` and `package-lock.json` for reproducible builds.
`migrate` and `seed` are available via `./dev.sh` (M2 #12). Never remove volumes
with `down -v`; the wrapper forbids it.
`composer lint` enforces PSR-12 on PHP in `src`, `public`, `db/migrations` and
`tests`. `composer test` currently checks only repository foundations. Add behavior
and regression tests with each implementation; the HTTP/database test framework
will be selected with the application skeleton (#7), not assumed here.
Docker, database migrations, Node/Tailwind builds and CI are not implemented yet.
Do not use undocumented application commands or mistake foundation checks for
integration coverage.
`composer lint` enforces PSR-12 on PHP in `src`, `public`, `config`, `bin`,
`templates`, `db/migrations` and `tests`. `composer test` runs foundation checks
and PHPUnit unit/integration tests. Add behavior and regression tests with each
implementation.
## Conventions
+58 -15
View File
@@ -3,10 +3,18 @@
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.
**Status:** M0 foundations only. There is no runnable web application, database
schema or Docker stack yet. Installation and a verified application quick start
are deliverables of [M1](https://git.keithsolomon.net/keith/MeshVault/milestones).
Do not deploy this scaffold as a service.
**Status:** The development stack, Phinx database migrations, and initial CLI import
engine are implemented. Imports have scanning, STL validation, streaming hashes,
verified copying, durable jobs, and repository integration. PostgreSQL-specific
integration tests exercise production migrations and two-session locking.
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
@@ -33,8 +41,10 @@ management, external imports, AI and multi-user libraries are outside MVP.
## Architecture and decisions
Docker, PostgreSQL and PHP application logic; PHP-rendered templates with Tailwind
CSS and browser JavaScript. Node is planned for asset builds, with mesh processing
introduced only when those future features are scoped. No PHP framework is selected.
CSS and browser JavaScript. Node runs inside an assets container for deterministic
locked builds and watch; no host Node is required. Slim 4, PHP-DI and Monolog are
installed, locked and wired. HTTP, configuration, logging and task wrappers are
verified on the remote development host.
Accepted decisions: [managed storage (#1)](https://git.keithsolomon.net/keith/MeshVault/issues/1),
[dates (#2)](https://git.keithsolomon.net/keith/MeshVault/issues/2),
@@ -49,7 +59,7 @@ Local planning notes and ADR copies are deliberately not versioned.
| `src/` | PHP application and domain code |
| `public/` | Future HTTP document root and generated browser assets |
| `db/migrations/` | Ordered PostgreSQL schema migrations |
| `docker/` | Future container definitions and service configuration |
| `docker/` | Development container definitions, configuration and setup |
| `assets/` | Source assets, including the project icon |
| `tests/` | Automated checks and future application fixtures/tests |
@@ -57,18 +67,51 @@ Directory READMEs describe boundaries; they are not application implementations.
## Development checks
With PHP 8.3+ and Composer 2 installed:
The only host prerequisite is Docker Compose v2+ with Linux containers. All PHP
and Node tooling runs inside containers.
```sh
composer install --no-interaction
composer validate --strict
composer lint
composer test
# Start the stack (builds images if needed)
./dev.sh up
# Or on Windows: .\dev.ps1 up
# Run checks inside the app container
./dev.sh test
./dev.sh lint
# Build minified assets inside the assets container
./dev.sh assets
# Watch for template changes
./dev.sh watch
```
These commands validate foundation files and PSR-12 configuration, not application
behavior. See [CONTRIBUTING.md](CONTRIBUTING.md) for conventions and test limitations.
Container-based development, migrations and frontend build commands arrive in M1.
`composer test` and `composer lint` also work on a host with PHP 8.3+ and Composer 2.
`composer audit` checks locked PHP dependencies. `migrate` and `seed` are available
via `./dev.sh` (M2 #12).
Asset builds use a locked `package-lock.json` inside a container; host Node is not
required. The generated `public/build/app.css` is served statically by nginx.
### Configuration and logging library
`MeshVault\Config\AppConfig::fromEnvironment(array $environment)` accepts explicit
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
manager, then pass `getenv()` to the factory after requiring `vendor/autoload.php`.
Compose injects the environment and the PHP entrypoint validates configuration as
the runtime user before starting FPM. The library never creates or chmods library roots.
Readonly properties: `environment`, `libraryPath`, `dbHost`, `dbPort`, `dbName`,
`dbUser`, `dbPassword`, `logLevel`. Configuration JSON/debug/serialization is redacted;
serialized configuration cannot be restored. Read credentials only at their point of
use: do not cast configuration to arrays or log credentials/messages containing them.
This is not a general-purpose sanitizer for arbitrary caller-supplied secrets.
`MeshVault\Logging\LoggerFactory::create($config)` returns a PSR-3 logger writing
newline-delimited JSON to stdout at the configured level. `forJob($logger, $jobId)`
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
and other jobs are unchanged. FPM forwarding was exercised during M1 runtime validation.
## License
+3
View File
@@ -0,0 +1,3 @@
@import "tailwindcss" source(none);
@source "../../templates/**/*.php";
@source "../../public/**/*.php";
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
$stage = 'autoload';
try {
require dirname(__DIR__) . '/vendor/autoload.php';
$stage = 'configuration';
\MeshVault\Config\AppConfig::fromEnvironment(getenv());
fwrite(STDOUT, "Configuration valid for runtime user.\n");
} catch (\Throwable $exception) {
$detail = $stage === 'configuration' && $exception instanceof \InvalidArgumentException
? $exception->getMessage() : 'Install dependencies and check bootstrap files.';
fwrite(STDERR, 'Configuration check failed: ' . $detail . "\n");
exit(1);
}
+55
View File
@@ -0,0 +1,55 @@
#!/usr/bin/env php
<?php
declare(strict_types=1);
use DI\ContainerBuilder;
use MeshVault\Config\AppConfig;
use MeshVault\Console\Console;
use MeshVault\Console\ConsoleServices;
use MeshVault\Console\LazyConsoleServices;
if (PHP_SAPI !== 'cli') {
fwrite(STDERR, 'meshvault must be run from the command line.' . PHP_EOL);
exit(1);
}
require dirname(__DIR__) . '/vendor/autoload.php';
// Built on first use so help and usage errors never require configuration.
$services = new LazyConsoleServices(static function (): ConsoleServices {
$builder = new ContainerBuilder();
$definitions = require dirname(__DIR__) . '/config/container.php';
$builder->addDefinitions($definitions(AppConfig::fromEnvironment(getenv())));
return $builder->build()->get(ConsoleServices::class);
});
$stopping = false;
$stopRequested = static function () use (&$stopping): bool {
if (function_exists('pcntl_signal_dispatch')) {
pcntl_signal_dispatch();
}
return $stopping;
};
if (function_exists('pcntl_signal')) {
$handler = static function () use (&$stopping): void {
$stopping = true;
};
pcntl_signal(SIGTERM, $handler);
pcntl_signal(SIGINT, $handler);
}
$console = new Console(
$services,
STDOUT,
STDERR,
static function (int $seconds) use ($stopRequested): void {
// Poll each second so a signal is honoured without waiting out the interval.
for ($elapsed = 0; $elapsed < $seconds && !$stopRequested(); $elapsed++) {
sleep(1);
}
},
$stopRequested,
);
exit($console->run(array_slice($argv, 1)));
+41
View File
@@ -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.'
+29
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
docs: update project-status with integration status (2026-10-14)
+19 -2
View File
@@ -4,14 +4,31 @@
"type": "project",
"license": "AGPL-3.0-only",
"require": {
"php": ">=8.3"
"php": ">=8.3",
"monolog/monolog": "^3.12",
"php-di/php-di": "^7.1",
"psr/log": "^3.0",
"slim/psr7": "^1.8",
"slim/slim": "^4.15.3"
},
"require-dev": {
"phpunit/phpunit": "^12.0",
"robmorgan/phinx": "^0.16.12",
"squizlabs/php_codesniffer": "^3.13.5"
},
"autoload": {
"psr-4": {
"MeshVault\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"MeshVault\\Tests\\": "tests/"
}
},
"scripts": {
"lint": "phpcs --standard=phpcs.xml.dist",
"test": "@php tests/foundation.php"
"test": ["@php tests/foundation.php", "@php vendor/phpunit/phpunit/phpunit"]
},
"config": {
"sort-packages": true,
Generated
+4216 -2
View File
File diff suppressed because it is too large. Load diff
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
use MeshVault\Catalog\CatalogService;
use MeshVault\Catalog\CatalogServiceInterface;
use MeshVault\Indexer\LegacyGuessedImportGroupingAdapter;
use MeshVault\Config\AppConfig;
use MeshVault\Console\ConsoleServices;
use MeshVault\Console\ContainerConsoleServices;
use MeshVault\Console\ImportWorkerFactory;
use MeshVault\Http\View;
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
use MeshVault\Indexer\FileEnumerator;
use MeshVault\Indexer\Filesystem\LocalFilesystem;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\ImportService;
use MeshVault\Indexer\ImportWorker;
use MeshVault\Indexer\SpaceChecker;
use MeshVault\Indexer\StlValidator;
use MeshVault\Indexer\StreamingHasher;
use MeshVault\Indexer\VerifiedCopier;
use MeshVault\Logging\LoggerFactory;
use MeshVault\Repository\CatalogRepositoryInterface;
use MeshVault\Repository\ImportJobRepositoryInterface;
use MeshVault\Repository\PdoCatalogRepository;
use MeshVault\Repository\PdoImportJobRepository;
use Psr\Container\ContainerInterface;
use Psr\Log\LoggerInterface;
return static fn (AppConfig $config): array => [
AppConfig::class => $config,
View::class => new View(dirname(__DIR__) . '/templates'),
LoggerInterface::class => static fn (): LoggerInterface => LoggerFactory::create($config),
PDO::class => static fn (): PDO => new PDO(
sprintf('pgsql:host=%s;port=%d;dbname=%s', $config->dbHost, $config->dbPort, $config->dbName),
$config->dbUser,
$config->dbPassword,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_EMULATE_PREPARES => false],
),
PathSafety::class => static fn (): PathSafety => new PathSafety($config->libraryPath),
FileEnumeratorInterface::class => static fn (ContainerInterface $c): FileEnumeratorInterface
=> new FileEnumerator(new LocalFilesystem(), $c->get(PathSafety::class)),
SpaceChecker::class => static fn (): SpaceChecker => new SpaceChecker(),
StreamingHasher::class => static fn (ContainerInterface $c): StreamingHasher
=> new StreamingHasher($c->get(PathSafety::class)),
StlValidator::class => static fn (ContainerInterface $c): StlValidator
=> new StlValidator($c->get(PathSafety::class)),
VerifiedCopier::class => static fn (ContainerInterface $c): VerifiedCopier
=> new VerifiedCopier($c->get(PathSafety::class)),
// Repositories share one PDO connection so the job repository's atomic() is a real transaction.
CatalogRepositoryInterface::class => static fn (ContainerInterface $c): CatalogRepositoryInterface
=> new PdoCatalogRepository($c->get(PDO::class)),
CatalogService::class => static fn (ContainerInterface $c): CatalogService
=> new CatalogService($c->get(CatalogRepositoryInterface::class)),
ImportJobRepositoryInterface::class => static fn (ContainerInterface $c): ImportJobRepositoryInterface
=> new PdoImportJobRepository($c->get(PDO::class)),
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(
$c->get(FileEnumeratorInterface::class),
$c->get(ImportJobRepositoryInterface::class),
$c->get(LegacyGuessedImportGroupingAdapter::class),
$c->get(SpaceChecker::class),
$config->libraryPath,
),
ImportWorkerFactory::class => static fn (ContainerInterface $c): ImportWorkerFactory
=> new class ($c) implements ImportWorkerFactory {
public function __construct(private readonly ContainerInterface $c)
{
}
public function __invoke(callable $shouldStop): ImportWorker
{
/** @var PathSafety $safety */
$safety = $this->c->get(PathSafety::class);
return new ImportWorker(
$this->c->get(ImportJobRepositoryInterface::class),
$this->c->get(CatalogServiceInterface::class),
$this->c->get(StreamingHasher::class),
$this->c->get(VerifiedCopier::class),
$this->c->get(StlValidator::class),
$safety,
$this->c->get(SpaceChecker::class),
$safety->libraryRoot(),
$this->c->get(LoggerInterface::class),
$shouldStop,
);
}
},
ConsoleServices::class => static fn (ContainerInterface $c): ConsoleServices => new ContainerConsoleServices(
$c->get(ImportService::class),
$c->get(ImportWorkerFactory::class),
),
];
+10
View File
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
use MeshVault\Http\HomeHandler;
use Slim\App;
return static function (App $app): void {
$app->get('/', HomeHandler::class);
};
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Initial schema: folders mirroring the managed library tree.
*
* Folders form a recursive tree structure where each folder has an optional
* parent folder. The root folder has parent_id = NULL. The path is stored
* as a materialized absolute path from the library root for efficient queries.
*/
final class CreateFolders extends AbstractMigration
{
public function change(): void
{
$this->table('folders', ['id' => 'id', 'primary_key' => 'id'])
->addColumn('parent_id', 'integer', ['null' => true, 'signed' => false])
->addColumn('name', 'string', ['limit' => 255])
->addColumn('path', 'text', ['null' => false])
->addColumn('added_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
->addForeignKey('parent_id', 'folders', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
->addIndex(['parent_id', 'name'], ['unique' => true, 'name' => 'idx_folders_parent_name'])
->addIndex(['path'], ['unique' => true, 'name' => 'idx_folders_path'])
->create();
// Root folder entry (will be created by application on first run)
// Application ensures root folder exists with path = '/' and parent_id = NULL
}
}
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Schema: models as logical catalog items.
*
* Each model belongs to a folder and represents a logical 3D model consisting
* of one or more files (STL parts, images, documents). Models have metadata
* including categories, tags, notes, and optional source URL.
*/
final class CreateModels extends AbstractMigration
{
public function change(): void
{
$this->table('models', ['id' => 'id', 'primary_key' => 'id'])
->addColumn('folder_id', 'integer', ['null' => false, 'signed' => false])
->addColumn('name', 'string', ['limit' => 255])
->addColumn('notes', 'text', ['null' => true])
->addColumn('source_url', 'string', ['limit' => 2048, 'null' => true])
->addColumn('added_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
->addColumn('updated_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
->addForeignKey('folder_id', 'folders', 'id', ['delete' => 'RESTRICT', 'update' => 'NO_ACTION'])
->addIndex(['folder_id', 'name'], ['unique' => true, 'name' => 'idx_models_folder_name'])
->addIndex(['added_at'], ['name' => 'idx_models_added_at'])
->create();
}
}
@@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Schema: files with hash, size, and lifecycle status.
*
* Files belong to a model and represent physical files in the managed library.
* Each file has a SHA-256 hash for change detection, size for space tracking,
* and a lifecycle status (Present, Missing, Updated, Duplicate).
* The managed path is relative to the library root.
*/
final class CreateFiles extends AbstractMigration
{
public function change(): void
{
$this->table('files', ['id' => 'id', 'primary_key' => 'id'])
->addColumn('model_id', 'integer', ['null' => false, 'signed' => false])
->addColumn('folder_id', 'integer', ['null' => false, 'signed' => false])
->addColumn('name', 'string', ['limit' => 255])
->addColumn('managed_path', 'text', ['null' => false])
->addColumn('hash', 'string', ['limit' => 64, 'null' => true]) // SHA-256 hex
->addColumn('size', 'biginteger', ['null' => true, 'signed' => false])
->addColumn('mime_type', 'string', ['limit' => 128, 'null' => true])
->addColumn('status', 'string', ['limit' => 32, 'default' => 'Present'])
->addColumn('added_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
->addColumn('updated_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
->addForeignKey('model_id', 'models', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
->addForeignKey('folder_id', 'folders', 'id', ['delete' => 'RESTRICT', 'update' => 'NO_ACTION'])
->addIndex(['model_id', 'managed_path'], ['unique' => true, 'name' => 'idx_files_model_path'])
->addIndex(['hash'], ['name' => 'idx_files_hash'])
->addIndex(['status'], ['name' => 'idx_files_status'])
->addIndex(['folder_id'], ['name' => 'idx_files_folder_id'])
->create();
}
}
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Schema: categories, tags, and model-tag associations.
*
* Categories are hierarchical (optional parent) for broad classification.
* Tags are flat, free-form labels. Models can have multiple tags via a
* many-to-many association table. Each model has exactly one category (nullable).
*/
final class CreateCategoriesTags extends AbstractMigration
{
public function change(): void
{
// Categories - hierarchical
$this->table('categories', ['id' => 'id', 'primary_key' => 'id'])
->addColumn('parent_id', 'integer', ['null' => true, 'signed' => false])
->addColumn('name', 'string', ['limit' => 128])
->addColumn('description', 'text', ['null' => true])
->addForeignKey('parent_id', 'categories', 'id', ['delete' => 'SET NULL', 'update' => 'NO_ACTION'])
->addIndex(['parent_id', 'name'], ['unique' => true, 'name' => 'idx_categories_parent_name'])
->create();
// Tags - flat
$this->table('tags', ['id' => 'id', 'primary_key' => 'id'])
->addColumn('name', 'string', ['limit' => 128])
->addIndex(['name'], ['unique' => true, 'name' => 'idx_tags_name'])
->create();
// Model-Tag many-to-many
$this->table('model_tags', ['id' => false, 'primary_key' => ['model_id', 'tag_id']])
->addColumn('model_id', 'integer', ['null' => false, 'signed' => false])
->addColumn('tag_id', 'integer', ['null' => false, 'signed' => false])
->addForeignKey('model_id', 'models', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
->addForeignKey('tag_id', 'tags', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
->create();
}
}
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Schema: uploaded thumbnails.
*
* Thumbnails are user-uploaded images associated with models.
* They are stored in the managed library under a thumbnails/ subdirectory
* and tracked separately from model files.
*/
final class CreateThumbnails extends AbstractMigration
{
public function change(): void
{
$this->table('thumbnails', ['id' => 'id', 'primary_key' => 'id'])
->addColumn('model_id', 'integer', ['null' => false, 'signed' => false])
->addColumn('managed_path', 'text', ['null' => false])
->addColumn('hash', 'string', ['limit' => 64, 'null' => false]) // SHA-256 hex
->addColumn('size', 'biginteger', ['null' => false, 'signed' => false])
->addColumn('mime_type', 'string', ['limit' => 128, 'null' => false])
->addColumn('width', 'integer', ['null' => true, 'signed' => false])
->addColumn('height', 'integer', ['null' => true, 'signed' => false])
->addColumn('uploaded_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
->addForeignKey('model_id', 'models', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
->addIndex(['model_id'], ['name' => 'idx_thumbnails_model_id'])
->addIndex(['hash'], ['unique' => true, 'name' => 'idx_thumbnails_hash'])
->create();
}
}
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
use Phinx\Migration\AbstractMigration;
/**
* Schema: import jobs and per-file import items.
*
* Import jobs track the overall import operation with progress, totals,
* and status. Import items represent individual files being imported,
* with their source path, destination, status, and any failure reason.
*/
final class CreateImportJobs extends AbstractMigration
{
public function change(): void
{
// Import jobs - overall operation tracking
$this->table('import_jobs', ['id' => 'id', 'primary_key' => 'id'])
->addColumn('source_path', 'text', ['null' => false])
// Status: Pending, Scanning, Copying, Completed, Failed, Cancelled
->addColumn('status', 'string', ['limit' => 32, 'default' => 'Pending'])
->addColumn('total_files', 'integer', ['null' => false, 'default' => 0, 'signed' => false])
->addColumn('processed_files', 'integer', ['null' => false, 'default' => 0, 'signed' => false])
->addColumn('total_bytes', 'biginteger', ['null' => false, 'default' => 0, 'signed' => false])
->addColumn('copied_bytes', 'biginteger', ['null' => false, 'default' => 0, 'signed' => false])
->addColumn('started_at', 'timestamp', ['null' => true, 'timezone' => true])
->addColumn('completed_at', 'timestamp', ['null' => true, 'timezone' => true])
->addColumn('created_at', 'timestamp', ['default' => 'CURRENT_TIMESTAMP', 'timezone' => true])
->addIndex(['status'], ['name' => 'idx_import_jobs_status'])
->addIndex(['created_at'], ['name' => 'idx_import_jobs_created_at'])
->create();
// Import items - per-file tracking
$this->table('import_items', ['id' => 'id', 'primary_key' => 'id'])
->addColumn('job_id', 'integer', ['null' => false, 'signed' => false])
->addColumn('source_path', 'text', ['null' => false])
->addColumn('managed_path', 'text', ['null' => true])
->addColumn('model_id', 'integer', ['null' => true, 'signed' => false])
->addColumn('hash', 'string', ['limit' => 64, 'null' => true]) // SHA-256 hex
->addColumn('size', 'biginteger', ['null' => true, 'signed' => false])
// Status: Pending, Copying, Completed, Failed, Skipped
->addColumn('status', 'string', ['limit' => 32, 'default' => 'Pending'])
->addColumn('failure_reason', 'text', ['null' => true])
->addColumn('started_at', 'timestamp', ['null' => true, 'timezone' => true])
->addColumn('completed_at', 'timestamp', ['null' => true, 'timezone' => true])
->addForeignKey('job_id', 'import_jobs', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
->addForeignKey('model_id', 'models', 'id', ['delete' => 'SET NULL', 'update' => 'NO_ACTION'])
->addIndex(['job_id', 'status'], ['name' => 'idx_import_items_job_status'])
->addIndex(['job_id', 'managed_path'], ['name' => 'idx_import_items_job_path'])
->addIndex(['hash'], ['name' => 'idx_import_items_hash'])
->create();
}
}
@@ -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();
}
}
+57
View File
@@ -5,3 +5,60 @@ changes versioned and repeatable; do not introduce a competing runner here.
Subsequent issues #13-18 add physical folders, logical models, distinct files,
metadata and import jobs. Application added_at timestamps are separate from file
mtime. Migrations must never implicitly delete underlying library files.
## Catalog identity — 20261012120001
Back up the database and **stop all writers** (web requests, import workers and
other database clients) before either upgrade or downgrade. Keep them stopped
through preflight and DDL. Preflight is not a concurrency lock.
Upgrade rejects duplicate managed paths and noncanonical paths, even without a
collision, reporting offending file IDs. It makes file ownership nullable with
RESTRICT on model deletion, replaces idx_files_model_path with unique
idx_files_managed_path and nonunique idx_files_model_id, and makes
idx_models_folder_name nonunique. Explicitly detach files before model deletion.
Rows, IDs, timestamps, dependent metadata and unrelated indexes/FKs are preserved.
Managed paths are lexical slash-separated relative names, preserved verbatim.
Empty/dot/dot-dot segments, backslashes, NUL, absolute paths and URI/drive prefixes
are rejected. Windows additionally rejects colons and trailing dots/spaces in
segments. Case and Unicode are not normalized. This is not filesystem case
comparison or symlink authorization; PathSafety still governs I/O. Migration
validation performs no filesystem access.
Investigate reported IDs and explicitly resolve catalog paths and ownership
against the library before retrying. No automatic merging, renaming, reassignment
or deletion repairs conflicts.
Downgrade to 20261011120006 rejects unassigned file IDs and duplicate same-folder
model titles before DDL. Explicitly assign files and disambiguate titles first.
Only then are NOT NULL, per-model path uniqueness, unique folder/title and the
old CASCADE model FK restored. Preflight failure leaves schema, data and migration
version unchanged.
Tasks 1–3 restore repository/importer compatibility and add the bounded catalog
service: explicit same-title identities, ambiguity-safe legacy lookup, nullable
registration and expected-owner associations using savepoint-backed transactions.
Association changes update only model_id, not physical files or home folders.
Task 4 verified the full suite on 2026-10-13:
- Windows PHP 8.3.35: foundation passed; PHPUnit 377 tests / 2032 assertions /
28 skips; standard lint passed (Composer script commands invoked directly).
- Fresh isolated PostgreSQL 17 / PHP 8.4.26 via `sh bin/test-postgres.sh`: Composer
tests 377 / 2160 / 7 skips and lint passed. Log on the Docker host:
`keith@192.168.2.23:/tmp/meshvault-catalog-tests-Ov96uNzU/pg-run-3.log`.
- The suite runs production migrations in generated schemas, including populated
down/up, dependent metadata, direct constraints, index preservation and unchanged
preflight-rejection snapshots. This does not replace an operator backup/restore
drill or deployment migration smoke acceptance.
- First PG run exposed four SQLite-trigger syntax errors in rollback tests;
dialect-specific test triggers now prove rollback after an actual earlier write.
Six new two-session cases verify 55P03 contention, stale/no-op behavior, ascending
execution parameters, nested rollback/lock release and root creation serialization.
They passed existing locking code; no induced deadlock or mutation-red claim.
No development database or historical workspace was touched. Independent full-slice
review approved this bounded implementation; #28 is not complete. Importer confirmation/recovery, broader
metadata/lifecycle contracts and unresolved M2/M3 acceptance remain open. See
`docs/project-status.md` for the evidence and limitations.
+83
View File
@@ -0,0 +1,83 @@
<?php
declare(strict_types=1);
use Phinx\Seed\AbstractSeed;
/**
* Seed script for default reference data.
*
* Populates initial categories and tags. Safe to re-run (idempotent).
*/
final class DefaultDataSeed extends AbstractSeed
{
public function run(): void
{
// Seed default categories
$categories = [
[
'name' => 'Aircraft',
'description' => 'Planes, helicopters, and drones',
],
[
'name' => 'Automotive',
'description' => 'Cars, trucks, and vehicles',
],
[
'name' => 'Characters',
'description' => 'Humanoid and creature models',
],
[
'name' => 'Props',
'description' => 'Objects and accessories',
],
[
'name' => 'Terrain',
'description' => 'Landscapes, buildings, and scenery',
],
[
'name' => 'Tools',
'description' => 'Functional items and gadgets',
],
];
foreach ($categories as $category) {
$exists = $this->getAdapter()->fetchRow(
"SELECT id FROM categories WHERE name = '"
. $category['name'] . "'"
);
if (!$exists) {
$this->getAdapter()->execute(
"INSERT INTO categories (name, description) VALUES ('"
. $category['name'] . "', '"
. $category['description'] . "')"
);
}
}
// Seed default tags
$tags = [
['name' => 'wip'],
['name' => 'completed'],
['name' => 'high-poly'],
['name' => 'low-poly'],
['name' => 'print-ready'],
['name' => 'needs-supports'],
['name' => 'articulated'],
['name' => 'modular'],
['name' => 'multi-part'],
['name' => 'single-piece'],
];
foreach ($tags as $tag) {
$exists = $this->getAdapter()->fetchRow(
"SELECT id FROM tags WHERE name = '" . $tag['name'] . "'"
);
if (!$exists) {
$this->getAdapter()->execute(
"INSERT INTO tags (name) VALUES ('" . $tag['name'] . "')"
);
}
}
}
}
+53
View File
@@ -0,0 +1,53 @@
# Deliberately use $args: unknown switches belong to the underlying command.
$ErrorActionPreference = 'Stop'
$Command = if ($args.Count) { $args[0] } else { '' }
$Forward = @($args | Select-Object -Skip 1)
try {
switch ($Command) {
'migrate' {
$ComposeArgs = @('run', '--rm', '--no-deps', '-T', '--entrypoint', 'sh', 'app', '-c', "vendor/bin/phinx migrate $($Forward -join ' ')")
}
'seed' {
$ComposeArgs = @('run', '--rm', '--no-deps', '-T', '--entrypoint', 'sh', 'app', '-c', "vendor/bin/phinx seed:run $($Forward -join ' ')")
}
'up' { $ComposeArgs = @('up', '-d', '--build') }
'down' {
foreach ($Argument in $Forward) {
if ($Argument -match '^--volumes|^-v$') {
[Console]::Error.WriteLine('Volume removal is forbidden by this wrapper')
exit 2
}
}
$ComposeArgs = @('down')
}
{ $_ -in 'test', 'lint' } {
$ComposeArgs = @('run', '--rm', '--no-deps', '-T', '--entrypoint', 'composer', 'app', $Command)
}
{ $_ -in 'import', 'worker-once' } {
$ComposeArgs = @('run', '--rm', '-T', '--entrypoint', 'php', 'app', 'bin/meshvault', $Command)
}
'worker' {
# Interactive so Ctrl+C reaches the worker's graceful-stop handler.
$ComposeArgs = @('run', '--rm', '--entrypoint', 'php', 'app', 'bin/meshvault', 'worker')
}
'assets' { $ComposeArgs = @('run', '--rm', '--no-deps', '-T', 'assets', 'npm', 'run', 'build', '--') }
{ $_ -in 'watch', 'assets:watch' } {
$ComposeArgs = @('run', '--rm', '--no-deps', '-T', 'assets', 'npm', 'run', 'watch', '--')
}
default {
[Console]::Error.WriteLine(
'Usage: dev.ps1 up|down|test|lint|assets|watch|migrate|seed|import|worker|worker-once [arguments]'
)
exit 2
}
}
Push-Location $PSScriptRoot
try {
& docker compose @ComposeArgs @Forward
$Result = $LASTEXITCODE
} finally { Pop-Location }
exit $Result
} catch {
[Console]::Error.WriteLine($_.Exception.Message)
exit 1
}
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
set -eu
cd -- "$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)"
cmd=${1:-}
if [ "$#" -gt 0 ]; then shift; fi
case "$cmd" in
migrate)
exec docker compose run --rm --no-deps -T --entrypoint sh app -c "vendor/bin/phinx migrate $*" ;;
seed)
exec docker compose run --rm --no-deps -T --entrypoint sh app -c "vendor/bin/phinx seed:run $*" ;;
down)
for arg do
case "$arg" in
--volumes*|-v) echo 'Volume removal is forbidden by this wrapper' >&2; exit 2 ;;
esac
done
exec docker compose down "$@" ;;
up) exec docker compose up -d --build "$@" ;;
test|lint) exec docker compose run --rm --no-deps -T --entrypoint composer app "$cmd" "$@" ;;
import|worker-once)
exec docker compose run --rm -T --entrypoint php app bin/meshvault "$cmd" "$@" ;;
worker)
# Interactive so Ctrl+C reaches the worker's graceful-stop handler.
exec docker compose run --rm --entrypoint php app bin/meshvault worker "$@" ;;
assets) exec docker compose run --rm --no-deps -T assets npm run build -- "$@" ;;
watch|assets:watch) exec docker compose run --rm --no-deps -T assets npm run watch -- "$@" ;;
*)
echo 'Usage: dev.sh up|down|test|lint|assets|watch|migrate|seed|import|worker|worker-once [arguments]' >&2
exit 2 ;;
esac
+88
View File
@@ -0,0 +1,88 @@
services:
web:
image: nginx:1.30-alpine
ports:
- "${MESHVAULT_HTTP_HOST:-127.0.0.1}:${MESHVAULT_HTTP_PORT:-8090}:80"
volumes:
- ./public:/var/www/html/public:ro
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
app:
build:
context: .
dockerfile: docker/php/Dockerfile
user: www-data
working_dir: /var/www/html
environment:
MESHVAULT_LIBRARY_DIR: ${MESHVAULT_LIBRARY_DIR:-/var/lib/meshvault/library}
MESHVAULT_ENV: ${MESHVAULT_ENV:-production}
MESHVAULT_LOG_LEVEL: ${MESHVAULT_LOG_LEVEL:-info}
MESHVAULT_DB_HOST: ${MESHVAULT_DB_HOST:-db}
MESHVAULT_DB_PORT: ${MESHVAULT_DB_PORT:-5432}
MESHVAULT_DB_NAME: ${MESHVAULT_DB_NAME:-meshvault}
MESHVAULT_DB_USER: ${MESHVAULT_DB_USER:-meshvault}
MESHVAULT_DB_PASSWORD: ${MESHVAULT_DB_PASSWORD:?Set MESHVAULT_DB_PASSWORD in .env}
volumes:
- ./public:/var/www/html/public:ro
- ./src:/var/www/html/src:ro
- ./config:/var/www/html/config:ro
- ./templates:/var/www/html/templates:ro
- ./tests:/var/www/html/tests:ro
- ./db:/var/www/html/db:ro
- ./bin:/var/www/html/bin:ro
- ./composer.json:/var/www/html/composer.json:ro
- ./phpunit.xml.dist:/var/www/html/phpunit.xml.dist:ro
- ./phpcs.xml.dist:/var/www/html/phpcs.xml.dist:ro
- ./dev.sh:/var/www/html/dev.sh:ro
- ./dev.ps1:/var/www/html/dev.ps1:ro
- ./README.md:/var/www/html/README.md:ro
- ./CONTRIBUTING.md:/var/www/html/CONTRIBUTING.md:ro
- ./LICENSE:/var/www/html/LICENSE:ro
- ./.editorconfig:/var/www/html/.editorconfig:ro
- ./.dockerignore:/var/www/html/.dockerignore:ro
- ./docker-compose.yml:/var/www/html/docker-compose.yml:ro
- ./docker:/var/www/html/docker:ro
- ./assets:/var/www/html/assets:ro
- ./phinx.php:/var/www/html/phinx.php:ro
- type: bind
source: ${MESHVAULT_LIBRARY_HOST_DIR:-./library}
target: ${MESHVAULT_LIBRARY_DIR:-/var/lib/meshvault/library}
bind:
create_host_path: false
depends_on:
db:
condition: service_healthy
db:
image: postgres:17-bookworm
environment:
POSTGRES_DB: ${MESHVAULT_DB_NAME:-meshvault}
POSTGRES_USER: ${MESHVAULT_DB_USER:-meshvault}
POSTGRES_PASSWORD: ${MESHVAULT_DB_PASSWORD:?Set MESHVAULT_DB_PASSWORD in .env}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
interval: 5s
timeout: 5s
retries: 12
start_period: 10s
assets:
build:
context: .
dockerfile: docker/assets/Dockerfile
profiles: [tools]
user: node
working_dir: /workspace
init: true
volumes:
- ./assets/css:/workspace/assets/css:ro
- ./public:/workspace/public:ro
- ./public/build:/workspace/public/build
- ./templates:/workspace/templates:ro
volumes:
pgdata:
+113 -6
View File
@@ -1,7 +1,114 @@
# Container configuration
# Development containers (M1)
M1 issue #6 adds nginx, PHP-FPM, PostgreSQL and asset build services. There is no
Compose file yet. Keep database data, managed library files and uploaded media
persistent and outside the application source. Mount import sources read-only
where possible. Node is asset tooling in MVP, not a required mesh-processing
runtime. Never bake credentials or user collections into images.
Four-service Docker Compose stack: nginx, unprivileged PHP-FPM, PostgreSQL and
an assets container for Tailwind CSS builds. The application layer (Slim 4,
PHP-DI, Monolog) is wired, but there is no catalog schema, migration runner or
asset pipeline beyond Tailwind. Do not expose this stack to the Internet.
## Prerequisites
- Docker Engine/Desktop with Linux containers and Docker Compose v2+.
- No host PHP, Composer or Node is required.
## First start
From the repository root, copy `.env.example` to `.env` and edit
`MESHVAULT_DB_PASSWORD` to a private, nonempty value. Never commit `.env` or print
expanded Compose configuration in shared logs; use `docker compose config --quiet`.
Create a **new dedicated** `library` directory (`mkdir library`). Do not use an
existing collection. On Linux, give container UID/GID 33 ownership of that new
empty directory, for example `sudo chown 33:33 ./library` and
`sudo chmod 750 ./library`. Alternatively, after building, use the narrowly scoped
one-off command below **only for this new empty directory**:
```sh
docker compose build app
docker compose run --rm --no-deps --user root app chown 33:33 /var/lib/meshvault/library
docker compose config --quiet
docker compose up -d --wait
```
Also ensure `public/build` is writable by the assets container user (`node`, UID
1000). On Linux, run:
```sh
mkdir -p public/build
chown 1000:1000 public/build
```
Docker Desktop bind permissions differ; verify writability using the runtime
identity rather than assuming host chmod works. The stack never automatically
creates or changes ownership of a configured library directory.
## Task wrappers
Use `dev.sh` (POSIX) or `dev.ps1` (PowerShell) from the repository root. They are
independent of your current working directory and propagate exit codes.
```sh
./dev.sh up # build and start services
./dev.sh down # stop services (volume removal is forbidden)
./dev.sh test # run composer test inside the app container
./dev.sh lint # run composer lint inside the app container
./dev.sh assets # build minified CSS inside the assets container
./dev.sh watch # start Tailwind watch inside the assets container
./dev.sh migrate # run database migrations (M2 #12)
./dev.sh seed # seed default reference data (M2 #12)
```
`down --volumes` and `down -v` are blocked by the wrapper. Never remove the named
`pgdata` volume or the library bind.
## Asset builds
Assets are built inside the `assets` service using a locked `package-lock.json`.
The generated `public/build/app.css` is served statically by nginx.
```sh
./dev.sh assets
```
Watch mode rebuilds on template changes:
```sh
./dev.sh watch
```
## Verification
Open http://127.0.0.1:8090/ for the landing page. `docker compose exec app id`
should report UID/GID 33. Check a disposable library write without becoming root:
```sh
docker compose exec app php -r '$p = getenv("MESHVAULT_LIBRARY_DIR") . "/write-check"; if (file_put_contents($p, "ok") === false) { exit(1); } unlink($p);'
```
`down` preserves the named `pgdata` volume and library bind. Changing database
credentials in `.env` does not change credentials in an initialized PostgreSQL
volume. Back up data before upgrades.
## Settings and boundaries
`.env.example` documents every current setting: Compose project, HTTP host/port,
DB name/user/password, host library path and absolute container library path.
The container library path must stay outside `/var/www/html` and must not overlap
system paths. The only required non-default value is the database password.
The default project is `meshvault-m1`; HTTP binds localhost:8090. Only nginx has a
published port. PostgreSQL readiness gates app startup. FPM itself runs as
`www-data`, not just its workers. nginx mounts only `public/` and executes only
`/index.php`; source, credentials and library are not web mounts.
Source mounts are explicit and read-only. No host `vendor` or `node_modules` is
mounted; dependencies are installed inside images from lockfiles. No Docker socket
is mounted. Startup configuration validation is provided by the entrypoint script.
Image branches: PHP 8.3 FPM Bookworm (security support to December 2027), PostgreSQL
17 Bookworm (support to November 2029), nginx 1.30 Alpine (stable), Node 24
Bookworm slim (LTS). Tags are versioned but move with patch updates, not digest
locks. PHP 8.3 compatibility is retained.
Remote verification uses only `/home/keith/Projects/MeshVault` after confirming
HOME, explicit project `meshvault-m1`, and an explicit source-only copy manifest.
Access from another machine via `ssh -L 8090:127.0.0.1:8090 keith@192.168.2.23`.
Never attach original collections or change unrelated services.
+7
View File
@@ -0,0 +1,7 @@
FROM node:24-bookworm-slim
WORKDIR /workspace
COPY --chown=node:node package.json package-lock.json ./
RUN chown node:node /workspace
USER node
RUN npm ci --no-audit --no-fund
CMD ["npm", "run", "build"]
+28
View File
@@ -0,0 +1,28 @@
server {
listen 80;
server_name _;
root /var/www/html/public;
index index.php;
server_tokens off;
autoindex off;
location ~ /\. {
return 404;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# Only the front controller may execute; never arbitrary uploaded PHP.
location = /index.php {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /var/www/html/public/index.php;
fastcgi_param HTTP_PROXY "";
fastcgi_pass app:9000;
}
location ~* \.php(?:/|$) {
return 404;
}
}
+20
View File
@@ -0,0 +1,20 @@
FROM php:8.3-fpm-bookworm
RUN apt-get update && apt-get install -y --no-install-recommends libonig-dev libxml2-dev libpq-dev unzip \
&& docker-php-ext-install mbstring dom pdo_pgsql \
&& rm -rf /var/lib/apt/lists/*
COPY --from=composer:2 /usr/bin/composer /usr/local/bin/composer
WORKDIR /var/www/html
# Dependencies are Linux-native and rebuilt from the lock, never host-mounted.
COPY composer.json composer.lock ./
RUN composer install --no-interaction --prefer-dist --no-progress
COPY bin ./bin
COPY docker/php/entrypoint.sh /usr/local/bin/meshvault-entrypoint
COPY docker/php/zz-meshvault.conf /usr/local/etc/php-fpm.d/zz-meshvault.conf
RUN chmod 755 /usr/local/bin/meshvault-entrypoint
RUN mkdir -p /var/www/html/.phpunit.cache && chown www-data:www-data /var/www/html/.phpunit.cache
# The entire FPM process runs as the same identity used for library checks.
USER www-data
ENTRYPOINT ["meshvault-entrypoint"]
CMD ["php-fpm"]
+11
View File
@@ -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"]
+26
View File
@@ -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
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -eu
# Image and Compose both select www-data; validation and FPM inherit this identity.
if [ "$1" = "php-fpm" ]; then
if [ "$(id -u)" = 0 ]; then
echo 'Refusing to validate or serve FPM as root; use www-data.' >&2
exit 1
fi
php /var/www/html/bin/check-config.php
fi
exec docker-php-entrypoint "$@"
+7
View File
@@ -0,0 +1,7 @@
[www]
; Preserve explicit Compose settings and forward Monolog JSON without prefixes.
clear_env = no
catch_workers_output = yes
decorate_workers_output = no
php_admin_flag[log_errors] = on
php_admin_flag[display_errors] = off
+61
View File
@@ -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.
+47
View File
@@ -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.
+185
View File
@@ -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.
@@ -0,0 +1,217 @@
# M3: Import and Indexing Engine 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:** Build scanning, validation, hashing, verified-copy primitives plus background worker, progress, and failure capture for managed imports.
**Architecture:** Three phases: (1) scan/copy primitives as reusable services in `src/Indexer/`; (2) repository layer for M4 #28 persistence; (3) background worker consuming `import_jobs`. Filesystem behind interfaces for testability.
**Tech Stack:** PHP 8.3+, PostgreSQL via Phinx, PSR-3 logging, PSR-4 `MeshVault\`, strict types, PSR-12. No new runtime deps.
**Spec:** `notes/backlog-m3-m4.json` issues 20-27 plus 28, and `notes/Roadmap.md`.
## Global Constraints
- PHP 8.3+, strict_types=1, PSR-12, four spaces, LF, UTF-8 no BOM.
- New classes `final readonly` where possible; constructor injection.
- PDO repositories, no ORM.
- Streams only; never load whole files into memory.
- `realpath()` checks; reject symlinks, `..`, dest outside library root.
- SHA-256 streaming, 128 KiB default buffer.
- Collision suffix `_NNN` before extension.
- Free-space safety margin 5% default.
- Library root from validated `AppConfig::libraryPath`.
- Error codes: `SCAN_EMPTY`, `SCAN_READ_ERROR`, `HASH_MISMATCH`, `DISK_FULL`, `COLLISION`, `SYMLINK_ESCAPE`, `PATH_TRAVERSAL`, `STL_INVALID`, `COPY_FAILED`, `DB_ERROR`.
- Atomic worker claim on `import_jobs`.
- Unit plus integration tests; 2+ failure-path tests per feature.
## Review Focus
1. Path safety vs traversal, symlinks, absolute escapes.
2. Streaming hash equality with `hash_file()`; mid-read changes.
3. Atomic publish temp-then-rename; no partial completes.
4. Worker idempotency on retry.
5. Disk-full pre-check plus mid-copy cleanup.
## Task 1.1 actual scope/status
- [x] Implemented only filesystem abstraction and path safety: `FilesystemInterface`,
`LocalFilesystem`, `PathSafety`, `PathSafetyException`, and unit tests for both services.
Native-style I/O failures return false; resource returns are PHPDoc and caller-owned.
Source checks allow existing external sources; destinations stay within the library
and may have missing suffixes. Relative paths, traversal, ancestor/dangling symlinks,
root-prefix siblings, and ambiguous Windows names are checked with structured reasons.
- Validation on local PHP 8.3.35: initial RED 22 missing-class assertion failures;
Windows alias regression also observed RED before its fix. Final focused suite:
23 tests, 102 assertions, 5 symlink skips (host cannot create them). `composer test`:
foundation checks pass; 110 tests, 604 assertions, 7 skips total. New PHP files pass
PSR-12; full `composer lint` still reports three unrelated existing errors in
`db/seeds/DefaultDataSeed.php` and `tests/Integration/MigrationTest.php` (not changed).
- Drive-relative review fix: `ensureSourceSafe()` now rejects any uppercase/lowercase
drive prefix without a following slash as `INVALID_PATH` before link inspection;
constructor roots inherit the same check. Existing relative/destination behavior is unchanged.
Regression RED: all 5 new cases failed with expected missing-rejection failures,
including realpath-confirmed existing source/root paths with upper/lowercase drives.
GREEN on Windows/PHP 8.3.35: focused `tests/Unit/Indexer` suite passed with 28 tests,
531 assertions, 5 symlink skips. `composer test` passed foundation checks and 115 tests,
1033 assertions, 7 skips. Scoped PHPCS using `phpcs.xml.dist` passed for
`src/Indexer/Filesystem/PathSafety.php` and `tests/Unit/Indexer/PathSafetyTest.php`.
Coverage also checks all 52 drive-letter variants with bare, file, and dot-relative suffixes.
- Limitations: checks are point-in-time, not race-proof; callers must control directory
mutation around I/O. Symlink behavior needs execution on a host permitting links;
no Linux runtime validation performed. No later M3 tasks, commits, or delegation.
## Task 1.2 actual scope/status
- [x] Implemented recursive `FileEnumerator`, `FileEnumeratorInterface`, and separate
PSR-4 `ScanResult`, `ScannedFile`, `ScanError` readonly DTOs. Manifest entries expose
canonical absolute `sourcePath`, safe forward-slash `relativePath`, `size`, and
`mimeType`; totals include only successful entries. Default allowlist is
stl/jpg/jpeg/png/gif/pdf/txt/md, matched case-insensitively; an explicit empty
allowlist selects nothing. Empty directories return an empty result without errors.
- Safety ruling: the global rejection of **all** symlinks overrides any older
follow-in-root-link wording. File, directory, dangling and ancestor links are
reported as `SYMLINK_ESCAPE`, even if the link has a disallowed extension, and
are never traversed. `PathSafety` checks run before canonicalization; the review fix
below tightens POSIX source spelling. Traversal retains `PATH_TRAVERSAL`. Invalid/missing/non-directory
roots and per-entry readability, resolution, stat, MIME and directory-read failures
produce actionable structured `SCAN_READ_ERROR` errors; independent siblings continue.
- Minimal filesystem API addition: `readDirectory(): array|false` returns entry names
without dot entries, backed by suppressed native `scandir()` failures. Existing
metadata calls and injected filesystem doubles cover deterministic I/O failures;
no new runtime dependencies or container changes. Iterative traversal avoids call
stack recursion; root depth is zero and the configurable maximum is 128 by default.
Exceeding it reports an error for that subtree without losing siblings.
- TDD/validation on Windows, PHP 8.3.35 (commands run from repository root):
- RED: `php vendor/phpunit/phpunit/phpunit tests/Unit/Indexer/FileEnumeratorTest.php`
produced 15 expected missing-FileEnumerator assertion failures before implementation.
- GREEN, same command: 15 tests, 143 assertions, 4 symlink privilege skips.
- Added direct adapter regression coverage for empty/nested listings, missing paths
and non-directory reads; enumeration tests cover mixed-case nested defaults,
disallowed extensions, custom/empty allowlists, invalid roots, six injected
per-entry failure modes, root failures, sibling continuation and depth limits.
- `php vendor/squizlabs/php_codesniffer/bin/phpcbf --standard=phpcs.xml.dist src/Indexer tests/Unit/Indexer/FileEnumeratorTest.php tests/Unit/Indexer/LocalFilesystemTest.php`
fixed generated CRLF and multiline formatting; no behavioral changes.
- `php vendor/phpunit/phpunit/phpunit tests/Unit/Indexer`: 44 tests, 679 assertions,
9 skips; success.
- `composer test`: foundation checks pass; 131 tests, 1181 assertions, 11 skips;
success (existing HTTP failure-path tests emit expected error log records).
- `php vendor/squizlabs/php_codesniffer/bin/phpcs --standard=phpcs.xml.dist src/Indexer tests/Unit/Indexer/FileEnumeratorTest.php tests/Unit/Indexer/LocalFilesystemTest.php`:
passes with no findings. Full-repository lint's previously documented unrelated
failures were not addressed.
- Review fixes (task 1.2):
- Policy ruling: POSIX source paths containing any backslash are rejected as
`INVALID_PATH` before normalization, including constructor library roots. Windows
backslash separators remain supported; relative/destination normalization is unchanged.
`FileEnumerator` now uses the canonical path returned by safety checks for injected
filesystem operations, retaining the adapter realpath call and its failure handling.
- Safety failure messages retain reason tokens and scan codes, but add corrective
guidance for missing, empty/NUL, traversal and symlink paths. Tests now require
directory-selection guidance rather than merely the phrase "source root".
- Added five tests: source/constructor platform policy, POSIX backslash-named ancestor
symlink with a separate slash-spelled decoy tree, traversal root/entry codes, zero-byte
metadata, and negative-size/empty-MIME sibling continuation. Existing failure doubles
continue to pass, including injected realpath failures.
- RED: Windows focused suite failed on missing-root guidance (49 tests, 688 assertions,
1 failure, 10 skips). A local WSL/PHP 8.5.4 standalone probe using real directories
and a backslash-named ancestor symlink failed source and constructor rejection and
demonstrated one accepted target file before the fix. After the fix all three probe
checks passed, with no accepted files. Probe fixtures were cleaned up; the ignored
`.phpunit.cache/posix-review-probe.php` remains because shell cleanup was blocked.
- GREEN on Windows/PHP 8.3.35:
`php vendor/phpunit/phpunit/phpunit tests/Unit/Indexer`: 49 tests, 722 assertions,
10 skips (nine host symlink-permission skips and one POSIX-only regression).
`composer test`: foundation checks pass; 136 tests, 1224 assertions, 12 skips
(the ten above plus two existing integration skips); expected HTTP error logs only.
`php vendor/squizlabs/php_codesniffer/bin/phpcs --standard=phpcs.xml.dist src/Indexer tests/Unit/Indexer`:
exit 0, no findings.
- Linux PHPUnit was attempted via WSL but cannot run because its PHP lacks dom,
mbstring and xmlwriter. No extensions were installed; the standalone native POSIX
RED/GREEN probe is not a substitute for a full Linux suite.
- Limitations: symlink PHPUnit cases require a link-capable host; Windows skips are
detailed above. Path checks are point-in-time, not race-proof, so callers must
control source-tree mutation. No file payload is loaded by the enumerator or opened
through `readStream`; native fileinfo may inspect content for MIME detection.
Directory name lists and the returned manifest are materialized in memory (not file
contents). Scan order is not a public guarantee. No free-space, hashing, copying,
persistence or worker work; no commits/merges/pushes. Task 1.1 source safety is tightened
only by the POSIX backslash policy above.
## Tasks 1.3-3.4 and delivery layer: actual scope/status
- [x] **1.3 SpaceChecker** (`src/Indexer/SpaceChecker.php`) — 5% default margin, injectable
free-space probe, `DISK_FULL` / `SPACE_CHECK_FAILED` reasons.
- [x] **1.4 StreamingHasher** (`src/Indexer/StreamingHasher.php`) — SHA-256 in 128 KiB chunks,
validates the source through `PathSafety` before and after the read.
- [x] **1.5 StlValidator** (`src/Indexer/StlValidator.php`) — binary and ASCII STL, facet-count
and terminator checks, `STL_INVALID` / `SCAN_READ_ERROR`.
- [x] **1.6/1.7 VerifiedCopier** (`src/Indexer/VerifiedCopier.php`) — temp copy, size and hash
verification, link-based publication, `_NNN` collision suffix, cleanup on failure.
- [x] **1.8 ImportService** (`src/Indexer/ImportService.php`) — scan, free-space preflight, then
one atomic manifest+job write. Scan errors become terminal `Failed` items.
- [x] **Phase 2 repositories** — `CatalogRepositoryInterface`, `ImportJobRepositoryInterface`,
`PdoRepository`, `PdoCatalogRepository`, `PdoImportJobRepository`. No schema changes.
- [x] **Phase 3 worker** (`src/Indexer/ImportWorker.php`) — single advisory `flock` over the
library, `resumeInterrupted()` under that lock, atomic claim, plan-before-publish durability,
per-item failure isolation, graceful-stop callback that leaves the job `Pending`.
- [x] **Delivery layer** — `src/Console/Console.php` (argument parsing, exit codes),
`ConsoleServices`, `ContainerConsoleServices`, `ImportWorkerFactory`, `LazyConsoleServices`,
`bin/meshvault`, container wiring in `config/container.php`, and `import` / `worker` /
`worker-once` subcommands in `dev.sh` and `dev.ps1`.
### Rulings
- **Ruling: no `/import` HTTP route in M3** — the backlog's HTTP surface has no authentication
yet, and any route accepting a source path would expose arbitrary server filesystem reads to
unauthenticated callers. Deferred to M6 behind auth. Cost if wrong: the UI trigger lands one
milestone later; the CLI covers the workflow meanwhile.
- **Ruling: CLI exit codes are 0 success / 1 usage / 2 runtime** — matches the existing wrappers'
use of exit 2 for refusal. Cost if wrong: scripted callers need remapping.
- **Ruling: services are constructed lazily in `bin/meshvault`** — `help` and usage errors must
not require a database or library directory. Cost if wrong: a misconfiguration surfaces one
call later, still as exit 2.
- **Ruling: grouping is by first directory component below the source root**, never inferred from
file contents. Cost if wrong: flat source trees produce one model per source directory.
### Validation (Windows, PHP 8.3.35)
- `composer test`: foundation checks pass; **285 tests, 1796 assertions, 18 skips**, no failures.
- `composer lint`: **exit 0, no findings repository-wide.** The two long-standing errors in
`db/seeds/DefaultDataSeed.php` and `tests/Integration/MigrationTest.php` were trailing-newline
violations and are now fixed; no logic in either file changed.
- `vendor\bin\phpunit tests/Unit/Console`: 22 tests, 71 assertions, 0 skips.
- `vendor\bin\phpunit --filter TaskRunner tests/Integration`: 1 test, 71 assertions — asserts the
wrappers forward `import`, `worker` and `worker-once` to `bin/meshvault` with arguments intact.
- 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.
### 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
- **PostgreSQL exercised on 2026-10-12.** The isolated remote Docker runner uses PostgreSQL 17
and production Phinx migrations for the repository and import pipeline suites. Two-session
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`
is available; on Windows the worker stops only at `--max-iterations`.
- Graceful stop is verified between items, not mid-copy.
- Symlink unit tests skip on hosts that cannot create links (11 skips here).
- 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.
+1236
View File
File diff suppressed because it is too large. Load diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "meshvault-assets",
"private": true,
"scripts": {
"build": "tailwindcss -i ./assets/css/app.css -o ./public/build/app.css --minify",
"watch": "tailwindcss -i ./assets/css/app.css -o ./public/build/app.css --watch=always"
},
"devDependencies": {
"@tailwindcss/cli": "4.3.3",
"tailwindcss": "4.3.3"
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
use Phinx\Config\Config;
/**
* Phinx configuration for MeshVault database migrations.
*
* Uses environment variables from the application configuration (AppConfig)
* for database connection. Migrations are stored in db/migrations/.
*/
// Load environment from getenv() - Compose injects these into the container
$dbHost = getenv('MESHVAULT_DB_HOST') ?: 'db';
$dbPort = (int) (getenv('MESHVAULT_DB_PORT') ?: 5432);
$dbName = getenv('MESHVAULT_DB_NAME') ?: 'meshvault';
$dbUser = getenv('MESHVAULT_DB_USER') ?: 'meshvault';
$dbPass = getenv('MESHVAULT_DB_PASSWORD') ?: '';
return [
'paths' => [
'migrations' => 'db/migrations',
'seeds' => 'db/seeds',
],
'environments' => [
'default_migration_table' => 'phinxlog',
'default_database' => 'development',
'development' => [
'adapter' => 'pgsql',
'host' => $dbHost,
'port' => $dbPort,
'name' => $dbName,
'user' => $dbUser,
'pass' => $dbPass,
'charset' => 'utf8',
],
],
'version_order' => 'creation',
'migration_base_class' => 'Phinx\Migration\AbstractMigration',
];
+11 -1
View File
@@ -1,4 +1,4 @@
<?xml version="1.0"?>
<?xml version="1.0"?>
<ruleset name="MeshVault">
<description>MeshVault PHP coding standard: PSR-12.</description>
<rule ref="PSR12"/>
@@ -6,6 +6,16 @@
<arg name="encoding" value="utf-8"/>
<file>src</file>
<file>public</file>
<file>config</file>
<file>bin</file>
<file>templates</file>
<file>db/migrations</file>
<file>db/seeds</file>
<file>tests</file>
<!-- Phinx migration/seed classes are global by framework convention. -->
<rule ref="PSR1.Classes.ClassDeclaration.MissingNamespace">
<include-pattern>db/migrations/*</include-pattern>
<include-pattern>db/seeds/*</include-pattern>
<severity>0</severity>
</rule>
</ruleset>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/12.5/phpunit.xsd"
bootstrap="vendor/autoload.php" cacheDirectory=".phpunit.cache"
failOnWarning="true" failOnRisky="true" failOnNotice="true" failOnDeprecation="true">
<testsuites>
<testsuite name="unit"><directory>tests/Unit</directory></testsuite>
<testsuite name="http"><directory>tests/Http</directory></testsuite>
<testsuite name="integration"><directory>tests/Integration</directory></testsuite>
</testsuites>
</phpunit>
+32
View File
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
// This boundary intentionally does not depend on autoloading, DI or templates.
ini_set('display_errors', '0');
$stage = 'autoload';
try {
require dirname(__DIR__) . '/vendor/autoload.php';
$stage = 'configuration';
$config = \MeshVault\Config\AppConfig::fromEnvironment(getenv());
$stage = 'application';
\MeshVault\Http\AppFactory::create($config)->run();
} catch (\Throwable $exception) {
$safeDetail = $stage === 'configuration' && $exception instanceof \InvalidArgumentException
? $exception->getMessage()
: 'Check dependencies and application bootstrap at ' . $exception->getFile() . ':' . $exception->getLine();
error_log((string) json_encode([
'message' => 'Application bootstrap failed',
'stage' => $stage,
'detail' => $safeDetail,
'exception_type' => $exception::class,
], JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE));
http_response_code(500);
header('Content-Type: text/html; charset=utf-8');
echo '<!doctype html><html lang="en"><meta charset="utf-8"><title>Internal Server Error</title>';
echo '<h1>Internal Server Error</h1>';
if (getenv('MESHVAULT_ENV') === 'development') {
echo '<pre>' . htmlspecialchars($safeDetail, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8') . '</pre>';
}
echo '</html>';
}
+82
View File
@@ -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));
}
}
+55
View File
@@ -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;
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace MeshVault\Catalog;
use InvalidArgumentException;
/** Lexical identity only; filesystem authorization remains PathSafety's responsibility. */
final class ManagedPath
{
public static function validate(string $path): string
{
if (
$path === '' || str_starts_with($path, '/') || str_contains($path, '\\')
|| str_contains($path, "\0") || preg_match('/^[a-zA-Z][a-zA-Z0-9+.-]*:/', $path)
) {
throw new InvalidArgumentException('Managed path must be a canonical relative path.');
}
foreach (explode('/', $path) as $segment) {
if (
$segment === '' || $segment === '.' || $segment === '..'
|| (PHP_OS_FAMILY === 'Windows'
&& (str_contains($segment, ':') || rtrim($segment, '. ') !== $segment))
) {
throw new InvalidArgumentException('Managed path contains a noncanonical segment.');
}
}
return $path;
}
}
+75
View File
@@ -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.');
}
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace MeshVault\Config;
use InvalidArgumentException;
use JsonSerializable;
use SensitiveParameter;
final readonly class AppConfig implements JsonSerializable
{
private function __construct(
public string $environment,
public string $libraryPath,
public string $dbHost,
public int $dbPort,
public string $dbName,
public string $dbUser,
#[SensitiveParameter] public string $dbPassword,
public string $logLevel,
) {
}
/** Values are explicit strings, as returned by getenv(); no implicit .env loading. */
public static function fromEnvironment(#[SensitiveParameter] array $environment): self
{
$required = static function (string $key) use ($environment): string {
$value = $environment[$key] ?? null;
if (!is_string($value) || trim($value) === '' || str_contains($value, "\0")) {
throw new InvalidArgumentException($key . ' must be a non-empty string without NUL bytes.');
}
return $value;
};
$choice = static function (string $key, string $default, array $choices) use ($environment): string {
$value = array_key_exists($key, $environment) ? $environment[$key] : $default;
if (!is_string($value) || !in_array($value, $choices, true)) {
throw new InvalidArgumentException($key . ' must be one of: ' . implode(', ', $choices) . '.');
}
return $value;
};
$appEnvironment = $choice('MESHVAULT_ENV', 'production', ['development', 'test', 'production']);
$level = $choice('MESHVAULT_LOG_LEVEL', 'info', [
'debug', 'info', 'notice', 'warning', 'error', 'critical', 'alert', 'emergency',
]);
$port = array_key_exists('MESHVAULT_DB_PORT', $environment) ? $environment['MESHVAULT_DB_PORT'] : '5432';
if (!is_string($port) || !preg_match('/^[0-9]{1,5}$/D', $port) || (int) $port < 1 || (int) $port > 65535) {
throw new InvalidArgumentException('MESHVAULT_DB_PORT must be decimal digits in the range 1..65535.');
}
$path = $required('MESHVAULT_LIBRARY_DIR');
$absolute = str_starts_with($path, '/')
|| (PHP_OS_FAMILY === 'Windows' && preg_match('~^(?:[A-Za-z]:[\\\\/]|\\\\\\\\)~', $path));
if (!$absolute || preg_match('/[\x00-\x1f\x7f]/', $path)) {
throw new InvalidArgumentException('MESHVAULT_LIBRARY_DIR must be an absolute local directory path.');
}
clearstatcache(true, $path);
if (!is_dir($path) || !is_writable($path)) {
throw new InvalidArgumentException(
'MESHVAULT_LIBRARY_DIR must exist as a directory writable by the runtime user.'
);
}
$database = [];
foreach (['MESHVAULT_DB_HOST', 'MESHVAULT_DB_NAME', 'MESHVAULT_DB_USER'] as $key) {
$value = $required($key);
if (trim($value) !== $value || preg_match('/[\x00-\x1f\x7f]/', $value)) {
throw new InvalidArgumentException($key . ' must not contain controls or surrounding whitespace.');
}
$database[$key] = $value;
}
return new self(
$appEnvironment,
$path,
$database['MESHVAULT_DB_HOST'],
(int) $port,
$database['MESHVAULT_DB_NAME'],
$database['MESHVAULT_DB_USER'],
$required('MESHVAULT_DB_PASSWORD'),
$level,
);
}
/** Diagnostic representations intentionally contain no configuration values. */
public function jsonSerialize(): array
{
return ['configuration' => '[redacted]'];
}
public function __debugInfo(): array
{
return $this->jsonSerialize();
}
public function __serialize(): array
{
return $this->jsonSerialize();
}
public function __unserialize(array $data): void
{
throw new \LogicException('AppConfig must be created from explicit environment input.');
}
}
+230
View File
@@ -0,0 +1,230 @@
<?php
declare(strict_types=1);
namespace MeshVault\Console;
use Closure;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
use MeshVault\Indexer\ImportException;
use Throwable;
/**
* Argument parsing and reporting for the meshvault CLI.
*
* Every dependency is injected, so the whole surface is testable without a
* database, a filesystem or a container. Exit codes are a contract:
* 0 success, 1 usage error, 2 runtime failure.
*/
final class Console
{
private const USAGE = 'Usage: meshvault <import|worker|worker-once|help> [arguments]';
private readonly Closure $sleep;
private readonly Closure $stopRequested;
/**
* @param resource $out
* @param resource $err
* @param callable(int): void $sleep
* @param callable(): bool $stopRequested
*/
public function __construct(
private readonly ConsoleServices $services,
private readonly mixed $out,
private readonly mixed $err,
callable $sleep,
callable $stopRequested,
) {
$this->sleep = Closure::fromCallable($sleep);
$this->stopRequested = Closure::fromCallable($stopRequested);
}
/** @param list<string> $argv Arguments after the program name. */
public function run(array $argv): int
{
$command = array_shift($argv);
return match ($command) {
null => $this->usage(self::USAGE),
'help', '--help', '-h' => $this->help(),
'import' => $this->import($argv),
'worker' => $this->worker($argv),
'worker-once' => $this->workerOnce($argv),
default => $this->usage('Unknown command: ' . $command . "\n" . self::USAGE),
};
}
private function help(): int
{
$this->write($this->out, implode("\n", [
self::USAGE,
'',
'Commands:',
' import <source-path> [--ext=stl,3mf] Scan a source tree and queue an import job.',
' worker [--sleep=N] [--max-iterations=N] Drain import jobs until stopped.',
' worker-once Claim and drain at most one job, then exit.',
' help Show this message.',
'',
'Options:',
' --ext=LIST Comma-separated extensions, with or without dots. Defaults to: '
. implode(',', FileEnumeratorInterface::DEFAULT_EXTENSIONS),
' --sleep=N Seconds to wait after an idle poll (default 5).',
' --max-iterations=N Stop after N polls instead of running until signalled.',
]));
return 0;
}
/** @param list<string> $argv */
private function import(array $argv): int
{
$source = null;
$extensions = FileEnumeratorInterface::DEFAULT_EXTENSIONS;
foreach ($argv as $argument) {
if (str_starts_with($argument, '--ext=')) {
$parsed = $this->extensions(substr($argument, 6));
if ($parsed === []) {
return $this->usage('--ext requires at least one extension, e.g. --ext=stl,3mf');
}
$extensions = $parsed;
continue;
}
if (str_starts_with($argument, '-')) {
return $this->usage('Unknown option: ' . $argument);
}
if ($source !== null) {
return $this->usage('import accepts exactly one source path.');
}
$source = $argument;
}
if ($source === null || trim($source) === '') {
return $this->usage('Usage: meshvault import <source-path> [--ext=stl,3mf]');
}
try {
$job = $this->services->import($source, $extensions);
} catch (Throwable $error) {
return $this->fail($error);
}
$this->write($this->out, 'Queued import job ' . $job . ' for ' . $source);
return 0;
}
/** @return list<string> */
private function extensions(string $raw): array
{
$extensions = [];
foreach (explode(',', $raw) as $candidate) {
$extension = strtolower(ltrim(trim($candidate), '.'));
if ($extension !== '' && !in_array($extension, $extensions, true)) {
$extensions[] = $extension;
}
}
return $extensions;
}
/** @param list<string> $argv */
private function workerOnce(array $argv): int
{
if ($argv !== []) {
return $this->usage('worker-once takes no arguments.');
}
try {
$job = $this->services->runWorkerOnce($this->stopRequested);
} catch (Throwable $error) {
return $this->fail($error);
}
$this->write(
$this->out,
$job === null ? 'No pending import jobs.' : 'Processed import job ' . $job . '.'
);
return 0;
}
/** @param list<string> $argv */
private function worker(array $argv): int
{
$sleep = 5;
$maxIterations = null;
foreach ($argv as $argument) {
if (str_starts_with($argument, '--sleep=')) {
$value = substr($argument, 8);
if (!$this->isNonNegativeInteger($value)) {
return $this->usage('--sleep must be a non-negative whole number of seconds.');
}
$sleep = (int) $value;
continue;
}
if (str_starts_with($argument, '--max-iterations=')) {
$value = substr($argument, 17);
if (!$this->isNonNegativeInteger($value) || (int) $value < 1) {
return $this->usage('--max-iterations must be a whole number of at least 1.');
}
$maxIterations = (int) $value;
continue;
}
return $this->usage(
str_starts_with($argument, '-')
? 'Unknown option: ' . $argument
: 'worker takes no positional arguments, got: ' . $argument
);
}
return $this->loop($sleep, $maxIterations);
}
private function loop(int $sleep, ?int $maxIterations): int
{
$iterations = 0;
while ($maxIterations === null || $iterations < $maxIterations) {
try {
$job = $this->services->runWorkerOnce($this->stopRequested);
} catch (Throwable $error) {
return $this->fail($error);
}
$iterations++;
if ($job !== null) {
$this->write($this->out, 'Processed import job ' . $job . '.');
}
// A stop requested mid-job has already been honoured by the worker.
if (($this->stopRequested)()) {
break;
}
if ($job === null) {
($this->sleep)($sleep);
}
}
$this->write($this->out, 'Import worker stopped after ' . $iterations . ' poll(s).');
return 0;
}
private function isNonNegativeInteger(string $value): bool
{
return preg_match('/^[0-9]{1,9}$/D', $value) === 1;
}
private function usage(string $message): int
{
$this->write($this->err, $message);
return 1;
}
private function fail(Throwable $error): int
{
$reason = match (true) {
$error instanceof ImportException => $error->reason,
$error instanceof PathSafetyException => $error->reason,
default => null,
};
// Unexpected errors may carry DSNs or credentials; report only the type.
$this->write($this->err, $reason === null
? 'Error: unexpected ' . $error::class . '. See the application log for details.'
: 'Error [' . $reason . ']: ' . $error->getMessage());
return 2;
}
/** @param resource $stream */
private function write(mixed $stream, string $message): void
{
fwrite($stream, $message . PHP_EOL);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace MeshVault\Console;
/**
* The only seam between argument parsing and the import engine.
*
* Keeping it this narrow lets the console be tested without a database,
* a filesystem, or a container.
*/
interface ConsoleServices
{
/**
* Queue an import job for a source tree.
*
* @param list<string> $extensions Lowercase extensions without leading dots.
* @return int The created job id.
*/
public function import(string $sourcePath, array $extensions): int;
/**
* Claim and drain at most one job.
*
* @param callable(): bool $shouldStop Consulted by the worker between items.
* @return int|null The processed job id, or null when the queue is idle.
*/
public function runWorkerOnce(callable $shouldStop): ?int;
}
+28
View File
@@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace MeshVault\Console;
use MeshVault\Indexer\ImportService;
/** Binds the console to the real import engine. */
final readonly class ContainerConsoleServices implements ConsoleServices
{
public function __construct(
private ImportService $importService,
private ImportWorkerFactory $workerFactory,
) {
}
public function import(string $sourcePath, array $extensions): int
{
return $this->importService->import($sourcePath, $extensions);
}
public function runWorkerOnce(callable $shouldStop): ?int
{
// The stop callback is per-run, so the worker is built per invocation.
return ($this->workerFactory)($shouldStop)->runOnce();
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace MeshVault\Console;
use MeshVault\Indexer\ImportWorker;
/** Builds an ImportWorker bound to a specific graceful-stop callback. */
interface ImportWorkerFactory
{
/** @param callable(): bool $shouldStop */
public function __invoke(callable $shouldStop): ImportWorker;
}
+39
View File
@@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace MeshVault\Console;
use Closure;
/**
* Defers engine construction until a command actually needs it, so usage
* errors and help never require a configured database or library.
*/
final class LazyConsoleServices implements ConsoleServices
{
private readonly Closure $factory;
private ?ConsoleServices $delegate = null;
/** @param callable(): ConsoleServices $factory */
public function __construct(callable $factory)
{
$this->factory = Closure::fromCallable($factory);
}
public function import(string $sourcePath, array $extensions): int
{
return $this->delegate()->import($sourcePath, $extensions);
}
public function runWorkerOnce(callable $shouldStop): ?int
{
return $this->delegate()->runWorkerOnce($shouldStop);
}
private function delegate(): ConsoleServices
{
return $this->delegate ??= ($this->factory)();
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace MeshVault\Exception;
use RuntimeException;
final class PathSafetyException extends RuntimeException
{
public const INVALID_PATH = 'INVALID_PATH';
public const ABSOLUTE_PATH = 'ABSOLUTE_PATH';
public const PATH_TRAVERSAL = 'PATH_TRAVERSAL';
public const SYMLINK_ESCAPE = 'SYMLINK_ESCAPE';
public const PATH_NOT_FOUND = 'PATH_NOT_FOUND';
public const NOT_DIRECTORY = 'NOT_DIRECTORY';
public const OUTSIDE_LIBRARY_ROOT = 'OUTSIDE_LIBRARY_ROOT';
public function __construct(public readonly string $reason)
{
parent::__construct('Path safety check failed: ' . $reason);
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace MeshVault\Http;
use DI\ContainerBuilder;
use MeshVault\Config\AppConfig;
use Psr\Http\Message\ServerRequestInterface;
use Psr\Log\LoggerInterface;
use Slim\App;
use Slim\Exception\HttpMethodNotAllowedException;
use Slim\Exception\HttpNotFoundException;
use Slim\Psr7\Response;
use Throwable;
final class AppFactory
{
public static function create(AppConfig $config): App
{
$builder = new ContainerBuilder();
$definitions = require dirname(__DIR__, 2) . '/config/container.php';
$builder->addDefinitions($definitions($config));
$container = $builder->build();
$app = \Slim\Factory\AppFactory::createFromContainer($container);
$routes = require dirname(__DIR__, 2) . '/config/routes.php';
$routes($app);
$app->addRoutingMiddleware();
$errors = $app->addErrorMiddleware($config->environment === 'development', true, false);
$errors->setDefaultErrorHandler(function (
ServerRequestInterface $request,
Throwable $exception,
bool $displayErrorDetails
) use ($container): Response {
$status = match (true) {
$exception instanceof HttpNotFoundException => 404,
$exception instanceof HttpMethodNotAllowedException => 405,
default => 500,
};
$title = match ($status) {
404 => 'Not Found',
405 => 'Method Not Allowed',
default => 'Internal Server Error',
};
if ($status === 500) {
// No exception messages, arguments, request data or config values in production logs.
$container->get(LoggerInterface::class)->error('HTTP request failed', [
'exception_type' => $exception::class,
'file' => $exception->getFile(),
'line' => $exception->getLine(),
]);
}
$response = new Response($status);
if ($exception instanceof HttpMethodNotAllowedException) {
$response = $response->withHeader('Allow', implode(', ', $exception->getAllowedMethods()));
}
$response->getBody()->write($container->get(View::class)->render('error', [
'title' => $title,
'detail' => $displayErrorDetails ? $exception::class . ': ' . $exception->getMessage() : '',
]));
return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
});
return $app;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace MeshVault\Http;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
final readonly class HomeHandler
{
public function __construct(private View $view)
{
}
public function __invoke(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
{
$response->getBody()->write($this->view->render('home', ['title' => 'MeshVault']));
return $response->withHeader('Content-Type', 'text/html; charset=utf-8');
}
}
+31
View File
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace MeshVault\Http;
final readonly class View
{
public function __construct(private string $directory)
{
}
public function render(string $template, array $data): string
{
if (!in_array($template, ['home', 'error'], true)) {
throw new \InvalidArgumentException('Unknown view.');
}
$escape = static fn (string $value): string => htmlspecialchars(
$value,
ENT_QUOTES | ENT_SUBSTITUTE,
'UTF-8'
);
ob_start();
try {
require $this->directory . '/' . $template . '.php';
return (string) ob_get_contents();
} finally {
ob_end_clean();
}
}
}
@@ -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,15 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer\Contracts;
use MeshVault\Indexer\ScanResult;
interface FileEnumeratorInterface
{
public const DEFAULT_EXTENSIONS = ['stl', 'jpg', 'jpeg', 'png', 'gif', 'pdf', 'txt', 'md'];
/** @param list<string> $extensions Case-insensitive extensions without leading dots. */
public function enumerate(string $sourceRoot, array $extensions = self::DEFAULT_EXTENSIONS): ScanResult;
}
@@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer\Contracts;
/** Native-style results: failed I/O returns false; callers own and close streams. */
interface FilesystemInterface
{
public function realpath(string $path): string|false;
public function isReadable(string $path): bool;
public function isFile(string $path): bool;
public function isDir(string $path): bool;
public function isSymlink(string $path): bool;
public function getSize(string $path): int|false;
public function getMimeType(string $path): string|false;
/** @return list<string>|false Entry names (not paths), excluding dot entries; false on read failure. */
public function readDirectory(string $path): array|false;
/** @return resource|false Binary stream opened for reading. */
public function readStream(string $path);
/** @return resource|false Binary stream opened for writing, truncating existing contents. */
public function writeStream(string $path);
public function rename(string $from, string $to): bool;
public function unlink(string $path): bool;
public function mkdir(string $path, int $permissions = 0777, bool $recursive = false): bool;
public function freeSpace(string $path): float|false;
}
@@ -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);
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
use MeshVault\Indexer\Contracts\FilesystemInterface;
use MeshVault\Indexer\Filesystem\PathSafety;
use RuntimeException;
/** Point-in-time manifest only: callers must prevent concurrent source-tree mutation. */
final readonly class FileEnumerator implements FileEnumeratorInterface
{
public function __construct(
private FilesystemInterface $filesystem,
private PathSafety $pathSafety,
private int $maxDepth = 128,
) {
}
public function enumerate(string $sourceRoot, array $extensions = self::DEFAULT_EXTENSIONS): ScanResult
{
$files = [];
$errors = [];
$totalBytes = 0;
$allowed = array_fill_keys(array_map('strtolower', $extensions), true);
try {
$root = $this->canonicalSource($sourceRoot);
if (!$this->filesystem->isDir($root) || !$this->filesystem->isReadable($root)) {
throw new RuntimeException('Choose an existing, readable directory.');
}
} catch (RuntimeException $error) {
return new ScanResult([], 0, [$this->scanError($sourceRoot, $error, 'Invalid source root: ')]);
}
// Explicit stack avoids PHP call-stack recursion. Root is depth zero.
$pending = [[$root, '', 0]];
while ($pending !== []) {
[$directory, $prefix, $depth] = array_pop($pending);
try {
$directory = $this->canonicalSource($directory);
if ($depth > $this->maxDepth) {
throw new RuntimeException('Maximum directory depth exceeded; use a shallower source root.');
}
if (!$this->filesystem->isReadable($directory)) {
throw new RuntimeException('Directory is not readable; check permissions.');
}
$entries = $this->filesystem->readDirectory($directory);
if ($entries === false) {
throw new RuntimeException('Cannot read directory; check permissions and availability.');
}
} catch (RuntimeException $error) {
$errors[] = $this->scanError($directory, $error);
continue;
}
foreach ($entries as $entry) {
$path = rtrim($directory, '/\\') . DIRECTORY_SEPARATOR . $entry;
try {
// Directory entries must remain a single portable path component.
if ($entry === '' || $entry === '.' || strpbrk($entry, '/\\') !== false) {
throw new PathSafetyException(PathSafetyException::INVALID_PATH);
}
$relative = $prefix . $entry;
$this->pathSafety->ensureRelative($relative);
$canonical = $this->canonicalSource($path);
if ($this->filesystem->isDir($canonical)) {
$pending[] = [$canonical, $relative . '/', $depth + 1];
continue;
}
if (!isset($allowed[strtolower(pathinfo($entry, PATHINFO_EXTENSION))])) {
continue;
}
if (!$this->filesystem->isFile($canonical) || !$this->filesystem->isReadable($canonical)) {
throw new RuntimeException('Not a readable regular file; check permissions and availability.');
}
$size = $this->filesystem->getSize($canonical);
if ($size === false || $size < 0) {
throw new RuntimeException('Cannot stat file size; check permissions and availability.');
}
$mime = $this->filesystem->getMimeType($canonical);
if ($mime === false || $mime === '') {
throw new RuntimeException('Cannot detect MIME type; check permissions and availability.');
}
if ($size > PHP_INT_MAX - $totalBytes) {
throw new RuntimeException('Total byte count exceeds the supported integer range.');
}
$files[] = new ScannedFile($canonical, $relative, $size, $mime);
$totalBytes += $size;
} catch (RuntimeException $error) {
$errors[] = $this->scanError($path, $error);
}
}
}
return new ScanResult($files, $totalBytes, $errors);
}
private function canonicalSource(string $path): string
{
// Safety must inspect lexical ancestors before canonicalization can hide links.
$path = $this->pathSafety->ensureSourceSafe($path);
if ($this->filesystem->isSymlink($path)) {
throw new PathSafetyException(PathSafetyException::SYMLINK_ESCAPE);
}
$canonical = $this->filesystem->realpath($path);
if ($canonical === false) {
throw new RuntimeException('Cannot resolve source; check existence and permissions.');
}
return $canonical;
}
private function scanError(string $path, RuntimeException $error, string $prefix = ''): ScanError
{
$safetyCodes = [PathSafetyException::SYMLINK_ESCAPE, PathSafetyException::PATH_TRAVERSAL];
$code = $error instanceof PathSafetyException && in_array($error->reason, $safetyCodes, true)
? $error->reason : ScanError::SCAN_READ_ERROR;
$message = $error->getMessage();
if ($error instanceof PathSafetyException) {
$guidance = match ($error->reason) {
PathSafetyException::PATH_NOT_FOUND, PathSafetyException::NOT_DIRECTORY =>
'Choose an existing, readable directory; check the path and permissions.',
PathSafetyException::INVALID_PATH =>
'Choose a non-empty directory path without NUL bytes or URL schemes; '
. 'use forward slashes on POSIX and an absolute path for Windows drives.',
PathSafetyException::PATH_TRAVERSAL => 'Choose a path without parent (..) components.',
PathSafetyException::SYMLINK_ESCAPE => 'Choose a path with no symlinks, including its ancestors.',
default => 'Choose a directory path permitted by the path safety policy.',
};
$message .= '. ' . $guidance;
}
return new ScanError($path, $code, $prefix . $message);
}
}
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer\Filesystem;
use finfo;
use MeshVault\Indexer\Contracts\FilesystemInterface;
/** Thin local I/O adapter; path authorization is the caller's responsibility. */
final readonly class LocalFilesystem implements FilesystemInterface
{
public function realpath(string $path): string|false
{
clearstatcache(true, $path);
return realpath($path);
}
public function isReadable(string $path): bool
{
clearstatcache(true, $path);
return is_readable($path);
}
public function isFile(string $path): bool
{
clearstatcache(true, $path);
return is_file($path);
}
public function isDir(string $path): bool
{
clearstatcache(true, $path);
return is_dir($path);
}
public function isSymlink(string $path): bool
{
clearstatcache(true, $path);
return is_link($path);
}
public function getSize(string $path): int|false
{
clearstatcache(true, $path);
return @filesize($path);
}
public function getMimeType(string $path): string|false
{
return @(new finfo(FILEINFO_MIME_TYPE))->file($path);
}
public function readDirectory(string $path): array|false
{
$entries = @scandir($path);
return $entries === false ? false : array_values(array_diff($entries, ['.', '..']));
}
/** @return resource|false */
public function readStream(string $path)
{
return @fopen($path, 'rb');
}
/** @return resource|false */
public function writeStream(string $path)
{
return @fopen($path, 'wb');
}
public function rename(string $from, string $to): bool
{
return @rename($from, $to);
}
public function unlink(string $path): bool
{
return @unlink($path);
}
public function mkdir(string $path, int $permissions = 0777, bool $recursive = false): bool
{
return @mkdir($path, $permissions, $recursive);
}
public function freeSpace(string $path): float|false
{
return @disk_free_space($path);
}
}
+146
View File
@@ -0,0 +1,146 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer\Filesystem;
use MeshVault\Exception\PathSafetyException as Error;
/**
* Point-in-time checks, not race-proof authorization. Callers must control directory
* mutation and recheck before I/O; no check here makes a later open/rename atomic.
*/
final readonly class PathSafety
{
private string $libraryRoot;
public function __construct(string $libraryRoot)
{
$root = $this->ensureSourceSafe($libraryRoot);
if (!is_dir($root)) {
throw new Error(Error::NOT_DIRECTORY);
}
$this->libraryRoot = $root;
}
/** The canonical, realpath-resolved library root. */
public function libraryRoot(): string
{
return $this->libraryRoot;
}
/** Sources may be outside the managed library, but must already exist. */
public function ensureSourceSafe(string $path): string
{
// On POSIX a backslash is a filename character, not a separator. Reject it
// before normalization so lexical checks cannot inspect a different tree.
if (PHP_OS_FAMILY !== 'Windows' && str_contains($path, '\\')) {
throw new Error(Error::INVALID_PATH);
}
$path = $this->validate($path);
// Drive-relative paths can resolve differently from the lexical link-check path.
if (preg_match('~^[A-Za-z]:(?!/)~', $path) === 1) {
throw new Error(Error::INVALID_PATH);
}
$this->rejectLinks($path);
$canonical = realpath($path);
if ($canonical === false) {
throw new Error(Error::PATH_NOT_FOUND);
}
return $canonical;
}
/** Relative destinations are resolved under the library; missing suffixes are allowed. */
public function ensureDestSafe(string $path): string
{
$path = $this->validate($path);
if (!$this->isAbsolute($path)) {
$this->ensureRelative($path);
$path = $this->libraryRoot . DIRECTORY_SEPARATOR . $path;
}
$this->rejectLinks($path);
$ancestor = $path;
$suffix = [];
while (($canonical = realpath($ancestor)) === false) {
$parent = dirname($ancestor);
if ($parent === $ancestor) {
throw new Error(Error::PATH_NOT_FOUND);
}
array_unshift($suffix, basename($ancestor));
$ancestor = $parent;
}
if ($suffix !== [] && !is_dir($canonical)) {
throw new Error(Error::NOT_DIRECTORY);
}
$root = rtrim($this->libraryRoot, '/\\');
$comparison = $canonical;
if (PHP_OS_FAMILY === 'Windows') {
$root = strtolower($root);
$comparison = strtolower($comparison);
}
if ($comparison !== $root && !str_starts_with($comparison, $root . DIRECTORY_SEPARATOR)) {
throw new Error(Error::OUTSIDE_LIBRARY_ROOT);
}
return $suffix === [] ? $canonical
: rtrim($canonical, '/\\') . DIRECTORY_SEPARATOR . implode(DIRECTORY_SEPARATOR, $suffix);
}
/** Validate without rewriting the supplied relative name. */
public function ensureRelative(string $path): string
{
$normalized = $this->validate($path);
if (str_starts_with($normalized, '/') || preg_match('/^[A-Za-z]:/', $normalized)) {
throw new Error(Error::ABSOLUTE_PATH);
}
return $path;
}
private function validate(string $path): string
{
if ($path === '' || str_contains($path, "\0") || str_contains($path, '://')) {
throw new Error(Error::INVALID_PATH);
}
$path = str_replace('\\', '/', $path);
foreach (explode('/', $path) as $index => $part) {
if ($part === '..') {
throw new Error(Error::PATH_TRAVERSAL);
}
// Win32 trims trailing dots/spaces and interprets colons as stream names.
$drive = $index === 0 && preg_match('/^[A-Za-z]:/', $part) === 1;
if (
PHP_OS_FAMILY === 'Windows'
&& (($part !== '.' && preg_match('/[. ]$/', $part))
|| (str_contains($part, ':') && !$drive))
) {
throw new Error(Error::INVALID_PATH);
}
}
return $path;
}
private function isAbsolute(string $path): bool
{
return str_starts_with($path, '/')
|| (PHP_OS_FAMILY === 'Windows' && preg_match('~^[A-Za-z]:/~', $path) === 1);
}
/** Inspect the lexical path before realpath can hide a link, including dangling links. */
private function rejectLinks(string $path): void
{
if (!$this->isAbsolute($path)) {
$path = getcwd() . '/' . $path;
}
$path = rtrim($path, '/');
while (true) {
clearstatcache(true, $path);
if (is_link($path)) {
throw new Error(Error::SYMLINK_ESCAPE);
}
$parent = dirname($path);
if ($parent === $path || $path === '') {
break;
}
$path = $parent;
}
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
use RuntimeException;
use Throwable;
/** Machine-readable reason independent of the human-readable message. */
final class ImportException extends RuntimeException
{
public function __construct(public readonly string $reason, string $message = '', ?Throwable $previous = null)
{
parent::__construct($message === '' ? $reason : $message, 0, $previous);
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
use MeshVault\Repository\ImportJobRepositoryInterface;
use Throwable;
/**
* Turns a source tree into a durable import job manifest.
*
* Legacy guessed grouping (not user-confirmed associations): each file is
* 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
* after the source directory itself. Every model lives in a folder of the same
* name directly under the library root, so scans never silently merge trees.
* No filesystem mutation happens here; the worker owns all copying.
*/
final readonly class ImportService
{
public function __construct(
private FileEnumeratorInterface $enumerator,
private ImportJobRepositoryInterface $jobs,
private LegacyGuessedImportGroupingAdapter|Contracts\ExplicitImportGroupingAdapterInterface $groupingAdapter,
private SpaceChecker $space,
private string $libraryRoot,
) {
}
/**
* Scan, preflight free space, then persist the manifest atomically.
*
* @param list<string> $extensions Case-insensitive extensions without dots.
* @throws ImportException SCAN_EMPTY, DISK_FULL, SPACE_CHECK_FAILED, DB_ERROR.
*/
public function import(
string $sourcePath,
array $extensions = FileEnumeratorInterface::DEFAULT_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);
}
// Preflight before writing any row so a full disk never leaves a job behind.
$this->space->check($this->libraryRoot, $scan->totalBytes);
try {
return $this->jobs->atomic(
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) {
throw $error;
} catch (Throwable $error) {
throw new ImportException('DB_ERROR', 'Could not persist the import job.', $error);
}
}
/** @return list<array<string, mixed>> */
private function manifest(string $sourcePath, ScanResult $scan, array $contexts, array &$decisions): array
{
$items = [];
foreach ($scan->files as $file) {
$context = $contexts[$file->relativePath] ?? $contexts['*'] ?? [];
$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[] = [
'source_path' => $file->sourcePath,
'size' => $file->size,
'model_id' => $modelId,
];
}
// Scan failures become terminal items so the job reports them durably.
foreach ($scan->errors as $error) {
$items[] = [
'source_path' => $error->sourcePath,
'size' => 0,
'model_id' => null,
'status' => 'Failed',
'failure_reason' => $error->code . ': ' . $error->message,
];
}
return $items;
}
}
+292
View File
@@ -0,0 +1,292 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Filesystem\PathSafety;
use MeshVault\Indexer\SpaceChecker;
use MeshVault\Catalog\CatalogServiceInterface;
use MeshVault\Repository\ImportJobRepositoryInterface;
use Psr\Log\LoggerInterface;
use Throwable;
use DomainException;
/**
* Serializes every managed-library mutation behind one advisory lock file and
* drives a claimed job item by item.
*
* Durability contract: the planned managed path plus the source digest are
* committed BEFORE any byte is published. After a crash the recovered worker
* re-reads that plan, verifies the expected destination, and completes the
* catalog instead of copying again, so restarts never orphan or duplicate
* files. Items fail independently; one failure never abandons the remaining
* work, and the job reports Failed only once every item is terminal.
* A graceful-stop callback is consulted before each item and leaves the job
* Pending (resumable) rather than terminal.
*/
final readonly class ImportWorker
{
public const LOCK_FILE = '.meshvault-import.lock';
/** @param null|callable(): bool $shouldStop Consulted before each item. */
public function __construct(
private ImportJobRepositoryInterface $jobs,
private CatalogServiceInterface $catalog,
private StreamingHasher $hasher,
private VerifiedCopier $copier,
private StlValidator $validator,
private PathSafety $pathSafety,
private SpaceChecker $spaceChecker,
private string $libraryRoot,
private LoggerInterface $logger,
private mixed $shouldStop = null,
) {
}
/**
* Claim and drain at most one job. Returns the job id, or null when idle.
*
* @throws ImportException LOCK_UNAVAILABLE when another worker holds the library.
*/
public function runOnce(): ?int
{
$this->jobs->assertPublicationAutocommit();
$lock = $this->lock();
try {
// Recovery runs under the lock, so no live worker can be reset.
$this->jobs->resumeInterrupted();
$job = $this->jobs->claimNext();
if ($job === null) {
return null;
}
$jobId = (int) $job['id'];
$this->drain($jobId);
return $jobId;
} finally {
flock($lock, LOCK_UN);
fclose($lock);
}
}
/** @return resource */
private function lock()
{
$path = $this->pathSafety->ensureDestSafe($this->libraryRoot . DIRECTORY_SEPARATOR . self::LOCK_FILE);
$handle = @fopen($path, 'c');
if ($handle === false) {
throw new ImportException('LOCK_UNAVAILABLE', 'Cannot open the library lock file.');
}
if (!flock($handle, LOCK_EX | LOCK_NB)) {
fclose($handle);
throw new ImportException('LOCK_UNAVAILABLE', 'Another import worker holds the library lock.');
}
return $handle;
}
private function drain(int $jobId): void
{
foreach ($this->jobs->items($jobId) as $item) {
if (!in_array($item['status'], ['Pending', 'Copying'], true)) {
continue;
}
if ($this->shouldStop !== null && ($this->shouldStop)()) {
// Graceful stop: return the job and its unfinished items to Pending.
$this->jobs->release($jobId);
$this->logger->info('Import worker stopped gracefully.', ['job_id' => $jobId]);
return;
}
$this->process($jobId, $item);
}
}
/** @param array<string, mixed> $item */
private function process(int $jobId, array $item): void
{
$itemId = (int) $item['id'];
$owned = null;
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);
$destination = $this->pathSafety->ensureDestSafe(
$this->libraryRoot . DIRECTORY_SEPARATOR
. str_replace('/', DIRECTORY_SEPARATOR, $plan['managed_path'])
);
$this->jobs->assertPublicationAutocommit();
$published = $this->publish($plan, $destination, $owned);
$relative = $this->relative($published);
// Catalog and progress commit together, so a crash here simply replays.
$this->catalog->atomic(function () use ($item, $itemId, $plan, $relative): void {
// Catalog locks precede item/job locks on the shared PDO connection.
try {
$this->catalog->registerFile(
$item['model_id'] === null ? null : (int) $item['model_id'],
$relative,
$plan['hash'],
$plan['size'],
null,
'Present'
);
} catch (DomainException $error) {
throw new ImportException('CATALOG_CONFLICT', $error->getMessage(), $error);
}
$this->jobs->updateManagedPath($itemId, $relative);
$this->jobs->finishItem($itemId, 'Completed');
});
} catch (Throwable $error) {
if ($owned !== null) {
$this->compensate($destination, $owned, $plan);
}
$this->failItem($jobId, $itemId, $error);
}
}
/**
* Persist the immutable plan (destination plus digest) before publication.
*
* @param array<string, mixed> $item
* @return array{source: string, managed_path: string, hash: string, size: int}
*/
private function plan(array $item): array
{
$source = (string) $item['source_path'];
if ($item['managed_path'] !== null && $item['hash'] !== null) {
// Recovered plan: trust the committed intent, never choose a new name.
return [
'source' => $source,
'managed_path' => (string) $item['managed_path'],
'hash' => (string) $item['hash'],
'size' => (int) $item['size'],
];
}
if (strtolower(pathinfo($source, PATHINFO_EXTENSION)) === 'stl') {
$this->validator->validate($source);
}
$hash = $this->hasher->hash($source);
$size = filesize($source);
if ($size === false) {
throw new ImportException('SCAN_READ_ERROR', 'Cannot determine the source size.');
}
$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);
return ['source' => $source, 'managed_path' => $managed, 'hash' => $hash, 'size' => $size];
}
/** @param array<string, mixed> $item */
private function managedPath(array $item, string $source): string
{
$folder = 'unfiled';
if ($item['model_id'] !== null) {
$aggregate = $this->catalog->aggregate((int) $item['model_id']);
if ($aggregate !== null) {
$folder = trim((string) $aggregate['folder']['path'], '/');
}
}
return ($folder === '' ? '' : $folder . '/') . basename(str_replace('\\', '/', $source));
}
/**
* Publish, or adopt an already-published destination left by a crashed run.
*
* @param array{source: string, managed_path: string, hash: string, size: int} $plan
* @return string The exact persisted path; collisions never change the plan.
*/
private function publish(array $plan, string $destination, ?array &$owned): 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.');
}
// Re-validate the destination with PathSafety and reject links before adopting.
$destination = $this->pathSafety->ensureDestSafe($destination);
clearstatcache(true, $destination);
if (is_file($destination)) {
// Idempotent restart: the planned destination already holds our bytes.
if (filesize($destination) === $plan['size'] && hash_file('sha256', $destination) === $plan['hash']) {
return $destination;
}
throw new ImportException('COLLISION', 'Persisted destination contains different bytes.');
} else {
// New file being copied: check free space before copying
$this->spaceChecker->check($directory, $plan['size']);
}
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
{
// Use the canonical (realpath-resolved) root from PathSafety so prefix comparison
// is reliable even when the configured library root contains symlinks or case differences.
$prefix = rtrim(str_replace('\\', '/', $this->pathSafety->libraryRoot()), '/') . '/';
$path = str_replace('\\', '/', $published);
if (!str_starts_with($path, $prefix)) {
throw new ImportException('PATH_TRAVERSAL', 'The published file escaped the managed library.');
}
return substr($path, strlen($prefix));
}
private function failItem(int $jobId, int $itemId, Throwable $error): void
{
$reason = match (true) {
$error instanceof ImportException => $error->reason,
$error instanceof PathSafetyException => $error->reason,
default => 'COPY_FAILED',
};
$this->logger->error('Import item failed.', [
'job_id' => $jobId,
'item_id' => $itemId,
'reason' => $reason,
]);
// Failures are per item: the loop continues with the remaining work.
$this->jobs->finishItem($itemId, 'Failed', $reason . ': ' . $error->getMessage());
}
}
@@ -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);
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
final readonly class ScanError
{
public const SCAN_READ_ERROR = 'SCAN_READ_ERROR';
public function __construct(
public string $sourcePath,
public string $code,
public string $message,
) {
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
final readonly class ScanResult
{
/** @param list<ScannedFile> $files @param list<ScanError> $errors */
public function __construct(
public array $files,
public int $totalBytes,
public array $errors,
) {
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
final readonly class ScannedFile
{
public function __construct(
public string $sourcePath,
public string $relativePath,
public int $size,
public string $mimeType,
) {
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
use Closure;
use InvalidArgumentException;
use Throwable;
/** Advisory preflight only: free space may change immediately after checking. */
final readonly class SpaceChecker
{
private Closure $freeSpace;
/** Margin is a fraction of payload bytes, not a fraction of disk capacity. */
public function __construct(private float $margin = 0.05, ?callable $freeSpace = null)
{
if (!is_finite($margin) || $margin < 0) {
throw new InvalidArgumentException('Space margin must be finite and nonnegative.');
}
$this->freeSpace = $freeSpace === null
? static fn (string $path): float|false => @disk_free_space($path)
: Closure::fromCallable($freeSpace);
}
/** Require ceil(bytes * (1 + margin)) available bytes on an existing destination directory. */
public function check(string $directory, int $bytes): void
{
if ($bytes < 0) {
throw new InvalidArgumentException('Payload size must be nonnegative.');
}
try {
$free = ($this->freeSpace)($directory);
} catch (Throwable $error) {
throw new ImportException('SPACE_CHECK_FAILED', 'Cannot query destination free space.', $error);
}
if ((!is_float($free) && !is_int($free)) || !is_finite((float) $free) || $free < 0) {
throw new ImportException('SPACE_CHECK_FAILED', 'Cannot determine destination free space.');
}
$required = ceil($bytes + $bytes * $this->margin);
if (!is_finite($required) || $free < $required) {
throw new ImportException('DISK_FULL', 'Insufficient free space including the safety margin.');
}
}
}
+204
View File
@@ -0,0 +1,204 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
use Closure;
use MeshVault\Indexer\Filesystem\PathSafety;
/**
* Structural validation, not manifoldness, winding or non-degeneracy validation.
* Empty meshes are rejected in both encodings. Exact binary length wins over a
* "solid" header. Binary attributes are opaque; all 12 floats must be finite.
* ASCII uses lowercase, one statement per LF/CRLF line (4096 bytes excluding LF),
* optional blank lines and printable names (opening/closing names need not match).
* One solid only; no comments, BOM, bare-CR lines or trailing non-whitespace.
* O(1) auxiliary memory, O(file size) time. Callers must prevent source mutation;
* PathSafety checks and final size checks do not provide a race-proof snapshot.
*/
final readonly class StlValidator
{
/** @param null|Closure(resource, int): (string|false) $reader Test seam; like fread. */
public function __construct(private PathSafety $pathSafety, private ?Closure $reader = null)
{
}
public function validate(string $path): void
{
$path = $this->pathSafety->ensureSourceSafe($path);
if (!is_file($path) || ($stream = @fopen($path, 'rb')) === false) {
throw new ImportException('SCAN_READ_ERROR', 'SCAN_READ_ERROR: Cannot open STL file.');
}
try {
$stat = @fstat($stream);
if ($stat === false || !is_int($stat['size']) || $stat['size'] < 0) {
throw new ImportException('SCAN_READ_ERROR', 'Cannot determine STL size.');
}
$size = $stat['size'];
$header = $this->readUpTo($stream, 84);
$count = strlen($header) === 84 ? unpack('Vcount', substr($header, 80, 4))['count'] : 0;
// Division avoids overflowing 84 + 50 * an untrusted uint32 count.
$binary = $size >= 84 && ($size - 84) % 50 === 0 && intdiv($size - 84, 50) === $count;
if ($binary) {
if ($count === 0) {
$this->invalid('Empty mesh.');
}
for ($index = 0; $index < $count; ++$index) {
$record = $this->readUpTo($stream, 50);
if (strlen($record) !== 50) {
$this->invalid('Truncated binary facet.');
}
foreach (unpack('g12', substr($record, 0, 48)) as $number) {
if (!is_finite($number)) {
$this->invalid('Non-finite binary coordinate or normal.');
}
}
}
if ($this->readUpTo($stream, 1) !== '') {
$this->invalid('Trailing binary data.');
}
} else {
if (@fseek($stream, 0) !== 0) {
throw new ImportException('SCAN_READ_ERROR', 'Cannot rewind STL file.');
}
$this->validateAscii($stream);
}
$after = @fstat($stream);
if ($after === false || $after['size'] !== $size) {
throw new ImportException('SCAN_READ_ERROR', 'STL size changed during validation.');
}
} finally {
fclose($stream);
}
}
/** @param resource $stream */
private function readUpTo($stream, int $length): string
{
$result = '';
while (strlen($result) < $length) {
$remaining = $length - strlen($result);
try {
$chunk = $this->reader === null ? @fread($stream, $remaining) : ($this->reader)($stream, $remaining);
} catch (\Throwable $error) {
throw new ImportException('SCAN_READ_ERROR', 'Failed reading STL stream.', $error);
}
if ($chunk === false || ($chunk === '' && !feof($stream)) || strlen($chunk) > $remaining) {
throw new ImportException('SCAN_READ_ERROR', 'Failed reading STL stream.');
}
if ($chunk === '') {
break;
}
$result .= $chunk;
}
return $result;
}
/** @param resource $stream @return \Generator<int, string> */
private function lines($stream): \Generator
{
$pending = '';
while (($chunk = $this->readUpTo($stream, 4096)) !== '') {
$pending .= $chunk;
while (($newline = strpos($pending, "\n")) !== false) {
if ($newline > 4096) {
$this->invalid('ASCII line exceeds 4096 bytes.');
}
yield substr($pending, 0, $newline);
$pending = substr($pending, $newline + 1);
}
if (strlen($pending) > 4096) {
$this->invalid('ASCII line exceeds 4096 bytes.');
}
}
if ($pending !== '') {
yield $pending;
}
}
/** @param resource $stream */
private function validateAscii($stream): void
{
$state = 'solid';
$hasFacet = false;
foreach ($this->lines($stream) as $line) {
$line = str_ends_with($line, "\r") ? substr($line, 0, -1) : $line;
if (preg_match('/[^\x20-\x7e\t]/', $line)) {
$this->invalid('Non-ASCII or control byte.');
}
$line = trim($line, " \t");
if ($line === '') {
continue;
}
switch ($state) {
case 'solid':
if (!preg_match('/^solid(?:[ \t].*)?$/D', $line)) {
$this->invalid('Expected solid.');
}
$state = 'facet';
break;
case 'facet':
if (preg_match('/^endsolid(?:[ \t].*)?$/D', $line)) {
if (!$hasFacet) {
$this->invalid('Empty mesh.');
}
$state = 'done';
break;
}
$this->vector($line, 'facet normal');
$state = 'outer';
break;
case 'outer':
if (!preg_match('/^outer[ \t]+loop$/D', $line)) {
$this->invalid('Expected outer loop.');
}
$state = 'vertex1';
break;
case 'vertex1':
case 'vertex2':
case 'vertex3':
$this->vector($line, 'vertex');
$state = ['vertex1' => 'vertex2', 'vertex2' => 'vertex3', 'vertex3' => 'endloop'][$state];
break;
case 'endloop':
case 'endfacet':
if ($line !== $state) {
$this->invalid('Expected ' . $state . '.');
}
if ($state === 'endfacet') {
$hasFacet = true;
$state = 'facet';
} else {
$state = 'endfacet';
}
break;
default:
$this->invalid('Trailing ASCII data.');
}
}
if ($state !== 'done') {
$this->invalid('Incomplete ASCII STL.');
}
}
private function vector(string $line, string $prefix): void
{
$number = '([+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?)';
$prefix = str_replace(' ', '[ \t]+', $prefix);
$pattern = '/^' . $prefix . '[ \t]+' . $number . '[ \t]+' . $number . '[ \t]+' . $number . '$/D';
if (!preg_match($pattern, $line, $m)) {
$this->invalid('Expected a normal or vertex with three decimal coordinates.');
}
foreach (array_slice($m, 1) as $value) {
if (!is_finite((float) $value)) {
$this->invalid('Non-finite ASCII coordinate or normal.');
}
}
}
private function invalid(string $message): never
{
throw new ImportException('STL_INVALID', $message);
}
}
+134
View File
@@ -0,0 +1,134 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
use Closure;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Filesystem\PathSafety;
use Throwable;
/**
* Bounded SHA-256 with best-effort mutation detection, not an atomic snapshot.
* Stat timestamps may have coarse resolution and some platforms lack useful inode
* identities. Callers must prevent concurrent writers for a strong guarantee.
*/
final readonly class StreamingHasher
{
public const CHUNK_SIZE = 131072;
private const FIELDS = ['dev', 'ino', 'mode', 'size', 'mtime', 'ctime'];
private ?Closure $chunkObserver;
/**
* Observer receives cumulative bytes after each nonempty chunk; exceptions propagate.
* Trusted fault hooks override native operations: open(path): resource|false,
* read(stream, length): string|false, stat(path): array|false,
* fstat(stream): array|false. The hasher owns and closes the opened stream.
*
* @param array<string, callable> $hooks
*/
public function __construct(
private PathSafety $pathSafety,
?callable $chunkObserver = null,
private array $hooks = [],
) {
$this->chunkObserver = $chunkObserver === null ? null : Closure::fromCallable($chunkObserver);
}
/** @throws ImportException Safety reasons are preserved; I/O reasons use HASH_* tokens. */
public function hash(string $path): string
{
$canonical = $this->safePath($path);
$before = $this->metadata('stat', $canonical);
if (($before['mode'] & 0170000) !== 0100000) {
throw new ImportException('HASH_NOT_REGULAR_FILE', 'Hashing requires a regular file.');
}
$stream = $this->io('open', 'HASH_OPEN_FAILED', $canonical);
if (!is_resource($stream)) {
throw new ImportException('HASH_OPEN_FAILED', 'Unable to open source for hashing.');
}
try {
$this->unchanged($before, $this->metadata('fstat', $stream));
$context = hash_init('sha256');
$bytes = 0;
while (!feof($stream)) {
$chunk = $this->io('read', 'HASH_READ_FAILED', $stream, self::CHUNK_SIZE);
if (!is_string($chunk) || ($chunk === '' && !feof($stream))) {
throw new ImportException('HASH_READ_FAILED', 'Source read failed or made no progress.');
}
if ($chunk === '') {
break;
}
$bytes += strlen($chunk);
if ($bytes > $before['size']) {
throw new ImportException('HASH_SOURCE_CHANGED', 'Source grew while hashing.');
}
hash_update($context, $chunk);
if ($this->chunkObserver !== null) {
($this->chunkObserver)($bytes);
}
}
$this->unchanged($before, $this->metadata('fstat', $stream));
if ($this->safePath($path) !== $canonical) {
throw new ImportException('HASH_SOURCE_CHANGED', 'Source path changed while hashing.');
}
$this->unchanged($before, $this->metadata('stat', $canonical));
if ($bytes !== $before['size']) {
throw new ImportException('HASH_SOURCE_CHANGED', 'Source length changed while hashing.');
}
return hash_final($context);
} finally {
fclose($stream);
}
}
private function safePath(string $path): string
{
clearstatcache(true);
try {
return $this->pathSafety->ensureSourceSafe($path);
} catch (PathSafetyException $error) {
throw new ImportException($error->reason, 'Unsafe hashing source: ' . $error->reason, $error);
}
}
private function metadata(string $operation, mixed $target): array
{
clearstatcache(true);
$stat = $this->io($operation, 'HASH_STAT_FAILED', $target);
foreach (self::FIELDS as $field) {
if (!is_array($stat) || !isset($stat[$field]) || !is_int($stat[$field])) {
throw new ImportException('HASH_STAT_FAILED', 'Unable to inspect source metadata.');
}
}
return $stat;
}
private function unchanged(array $before, array $after): void
{
foreach (self::FIELDS as $field) {
if ($before[$field] !== $after[$field]) {
throw new ImportException('HASH_SOURCE_CHANGED', 'Source metadata changed while hashing: ' . $field);
}
}
}
private function io(string $operation, string $reason, mixed ...$arguments): mixed
{
try {
if (isset($this->hooks[$operation])) {
return ($this->hooks[$operation])(...$arguments);
}
return match ($operation) {
'open' => @fopen($arguments[0], 'rb'),
'read' => @fread($arguments[0], $arguments[1]),
'stat' => @stat($arguments[0]),
'fstat' => @fstat($arguments[0]),
};
} catch (Throwable $error) {
throw new ImportException($reason, 'Hashing I/O failed: ' . $operation, $error);
}
}
}
+206
View File
@@ -0,0 +1,206 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
use MeshVault\Exception\PathSafetyException;
use MeshVault\Indexer\Filesystem\PathSafety;
use Throwable;
/**
* Verified, no-overwrite publication via hard link (never rename fallback).
* PathSafety checks are point-in-time: callers MUST control source/destination
* tree mutation. Native PHP streams lack openat/O_NOFOLLOW; checks cannot prevent
* adversarial path replacement between checking and I/O. Atomic publication does
* not imply power-loss durability (no directory fsync), nor source immutability.
*/
final readonly class VerifiedCopier
{
/**
* Trusted test seams only; replacements follow native return conventions:
* read(resource, int): string|false; write(resource, string): int|false;
* flush(resource): bool; link(string $temporary, string $destination): bool.
* A successful replacement must actually perform the native operation.
*
* @param array<string, callable> $operations
*/
public function __construct(private PathSafety $pathSafety, private array $operations = [])
{
foreach ($operations as $name => $operation) {
if (!in_array($name, ['read', 'write', 'flush', 'link'], true) || !is_callable($operation)) {
throw new \InvalidArgumentException('Unknown or non-callable copy operation: ' . $name);
}
}
}
/**
* Copy an existing regular source to a filename under the managed library.
* Parent must exist. Returns absolute published path; collisions try _001
* 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
* COPY_FAILED, HASH_MISMATCH (size or digest), COLLISION, INVALID_ARGUMENT.
*/
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) {
throw new ImportException('INVALID_ARGUMENT', 'Expected a nonnegative size and SHA-256 hex digest.');
}
$source = $this->pathSafety->ensureSourceSafe($source);
$destination = $this->pathSafety->ensureDestSafe($destination);
if (!is_file($source) || !is_dir(dirname($destination))) {
throw new ImportException('COPY_FAILED', 'Source must be regular and destination parent must exist.');
}
$input = $output = null;
$temporary = null;
try {
$input = @fopen($this->pathSafety->ensureSourceSafe($source), 'rb');
if ($input === false) {
throw new ImportException('COPY_FAILED', 'Cannot open source.');
}
$candidate = dirname($destination) . DIRECTORY_SEPARATOR
. '.meshvault-' . bin2hex(random_bytes(16)) . '.tmp';
$candidate = $this->pathSafety->ensureDestSafe($candidate);
$output = @fopen($candidate, 'x+b');
if ($output === false) {
throw new ImportException('COPY_FAILED', 'Cannot exclusively create temporary file.');
}
$temporary = $candidate; // Only clean up a file we successfully created.
$size = 0;
$hash = hash_init('sha256');
while (!feof($input)) {
$bytes = $this->invoke('read', $input, 131072);
if ($bytes === false || ($bytes === '' && !feof($input))) {
throw new ImportException('COPY_FAILED', 'Source read failed or stalled.');
}
$length = strlen($bytes);
$size += $length;
if ($size > $expectedSize) {
throw new ImportException('HASH_MISMATCH', 'Source exceeds expected size.');
}
hash_update($hash, $bytes);
for ($offset = 0; $offset < $length; $offset += $written) {
$written = $this->invoke('write', $output, substr($bytes, $offset));
if (!is_int($written) || $written <= 0 || $written > $length - $offset) {
throw new ImportException('COPY_FAILED', 'Temporary write failed or stalled.');
}
}
}
if ($size !== $expectedSize || !hash_equals(strtolower($expectedSha256), hash_final($hash))) {
throw new ImportException('HASH_MISMATCH', 'Source size or SHA-256 differs from expected metadata.');
}
if (!$this->invoke('flush', $output)) {
throw new ImportException('COPY_FAILED', 'Temporary flush failed.');
}
$this->verify($output, $expectedSize, strtolower($expectedSha256));
fclose($input);
$input = null;
if (!fclose($output)) {
throw new ImportException('COPY_FAILED', 'Temporary close failed.');
}
$output = null;
return $this->publish($temporary, $destination, $exact);
} catch (ImportException | PathSafetyException $error) {
throw $error;
} catch (Throwable $error) {
throw new ImportException('COPY_FAILED', 'Copy operation failed.', $error);
} finally {
if (is_resource($input)) {
fclose($input);
}
if (is_resource($output)) {
fclose($output);
}
if ($temporary !== null) {
// Cleanup must never throw: a thrown exception here would mask the original
// error (HASH_MISMATCH, COPY_FAILED, etc.) that triggered the finally block.
// Publication may already have succeeded; never remove its final name.
@unlink($temporary);
}
}
}
/** 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 */
private function verify($stream, int $size, string $digest): void
{
if (!rewind($stream)) {
throw new ImportException('COPY_FAILED', 'Cannot rewind temporary file.');
}
$hash = hash_init('sha256');
$actualSize = 0;
while (!feof($stream)) {
$bytes = $this->invoke('read', $stream, 131072);
if ($bytes === false || ($bytes === '' && !feof($stream))) {
throw new ImportException('COPY_FAILED', 'Cannot read back temporary file.');
}
$actualSize += strlen($bytes);
hash_update($hash, $bytes);
}
if ($actualSize !== $size || !hash_equals($digest, hash_final($hash))) {
throw new ImportException('HASH_MISMATCH', 'Written size or SHA-256 differs from expected metadata.');
}
}
private function publish(string $temporary, string $destination, bool $exact): string
{
$extension = pathinfo($destination, PATHINFO_EXTENSION);
$stem = $extension === '' ? $destination : substr($destination, 0, -strlen($extension) - 1);
for ($suffix = 0; $suffix <= ($exact ? 0 : 999); ++$suffix) {
$candidate = $suffix === 0 ? $destination
: $stem . sprintf('_%03d', $suffix) . ($extension === '' ? '' : '.' . $extension);
$candidate = $this->pathSafety->ensureDestSafe($candidate);
clearstatcache(true, $candidate);
if (file_exists($candidate)) {
continue;
}
$this->pathSafety->ensureDestSafe($temporary);
if ($this->invoke('link', $temporary, $candidate)) {
return $candidate;
}
// A failed link is a collision only if another entry now occupies it.
$this->pathSafety->ensureDestSafe($candidate);
clearstatcache(true, $candidate);
if (!file_exists($candidate)) {
throw new ImportException('COPY_FAILED', 'Hard-link publication failed.');
}
}
throw new ImportException('COLLISION', 'Destination and all 999 suffixes are occupied.');
}
private function invoke(string $operation, mixed ...$arguments): mixed
{
if (isset($this->operations[$operation])) {
return ($this->operations[$operation])(...$arguments);
}
return match ($operation) {
'read' => @fread(...$arguments),
'write' => @fwrite(...$arguments),
'flush' => @fflush(...$arguments),
'link' => @link(...$arguments),
};
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace MeshVault\Logging;
use Psr\Log\AbstractLogger;
use Psr\Log\LoggerInterface;
use Stringable;
/** Immutable job scope; no mutable processor shared with another job. */
final class JobLogger extends AbstractLogger
{
public function __construct(
public readonly LoggerInterface $logger,
private readonly string $jobId,
) {
}
public function log($level, string|Stringable $message, array $context = []): void
{
$context['job_id'] = $this->jobId;
$this->logger->log($level, $message, $context);
}
}
+30
View File
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace MeshVault\Logging;
use MeshVault\Config\AppConfig;
use Monolog\Formatter\JsonFormatter;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Psr\Log\LoggerInterface;
final class LoggerFactory
{
public static function create(AppConfig $config): LoggerInterface
{
$handler = new StreamHandler('php://stdout', $config->logLevel);
$handler->setFormatter(new JsonFormatter(JsonFormatter::BATCH_MODE_JSON, true));
return new Logger('meshvault', [$handler]);
}
public static function forJob(LoggerInterface $logger, string $jobId): LoggerInterface
{
// Re-scoping an existing job replaces its ID without mutating either logger.
if ($logger instanceof JobLogger) {
$logger = $logger->logger;
}
return new JobLogger($logger, $jobId);
}
}
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
namespace MeshVault\Repository;
interface CatalogRepositoryInterface
{
/** Run an operation atomically, using a savepoint inside a caller transaction. */
public function atomic(callable $operation): mixed;
/** Always create a new identity, including for an existing folder/title pair. */
public function createModel(string $folderPath, string $modelName): int;
/** Legacy lookup: create on zero matches, reuse one, reject ambiguity. */
public function ensureModel(string $folderPath, string $modelName): int;
/** 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(
?int $modelId,
string $managedPath,
string $hash,
int $size,
?string $mime = null,
string $status = 'Present'
): int;
/** @return array{model: array, folder: array, files: array}|null */
public function aggregate(int $modelId): ?array;
/**
* Atomically change a nonempty list of distinct positive file IDs.
* Null expected ownership means unassigned, never a wildcard.
* Invalid arguments throw InvalidArgumentException; missing/stale identities throw DomainException.
* SQL and lock failures propagate unchanged, without retry.
*/
public function changeFileOwner(array $fileIds, ?int $expectedOwnerId, ?int $targetOwnerId): void;
/** 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;
}
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace MeshVault\Repository;
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 get(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;
/** Caller MUST hold the global library flock; never use age-based recovery. */
public function resumeInterrupted(): void;
public function planItem(int $itemId, string $managedPath, string $hash, int $size): void;
public function updateManagedPath(int $itemId, string $managedPath): void;
public function finishItem(int $itemId, string $status, ?string $failureReason = null): void;
public function release(int $jobId): void;
public function cancel(int $jobId): void;
public function fail(int $jobId, string $failureReason): void;
public function completeEmpty(int $jobId): void;
/** All repositories participating in the callback must share this PDO connection. */
public function atomic(callable $callback): mixed;
}
+319
View File
@@ -0,0 +1,319 @@
<?php
declare(strict_types=1);
namespace MeshVault\Repository;
use DomainException;
use InvalidArgumentException;
use MeshVault\Catalog\ManagedPath;
use MeshVault\Catalog\ModelMetadata;
use PDO;
final class PdoCatalogRepository extends PdoRepository implements CatalogRepositoryInterface
{
public function atomic(callable $operation): mixed
{
return $this->transaction($operation);
}
public function createModel(string $folderPath, string $modelName): int
{
return $this->createOrFindModel($folderPath, $modelName, false);
}
public function ensureModel(string $folderPath, string $modelName): int
{
return $this->createOrFindModel($folderPath, $modelName, true);
}
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, $legacy): int {
$this->lockRoot();
$folder = $this->ensureFolder($folderPath);
if ($legacy) {
$matches = $this->execute(
'SELECT id FROM models WHERE folder_id = ? AND name = ? ORDER BY id LIMIT 2',
[$folder, $modelName]
)->fetchAll(PDO::FETCH_COLUMN);
if (count($matches) > 1) {
throw new DomainException('Ambiguous model title in home folder.');
}
if ($matches !== []) {
return (int) $matches[0];
}
}
return (int) $this->row(
'INSERT INTO models (folder_id, name) VALUES (?, ?) RETURNING id',
[$folder, $modelName]
)['id'];
});
}
private function lockRoot(): void
{
$this->ensureFolder('/');
$this->execute("UPDATE folders SET name = name WHERE path = '/'");
}
public function 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(
?int $modelId,
string $managedPath,
string $hash,
int $size,
?string $mime = null,
string $status = 'Present'
): int {
$this->validateStatus($status);
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.');
}
return $this->transaction(function () use ($modelId, $managedPath, $hash, $size, $mime, $status): int {
// Serialize catalog writers on the root row, including paths assigned to different models.
$this->lockRoot();
$lock = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? ' FOR UPDATE' : '';
$existing = $this->row('SELECT * FROM files WHERE managed_path = ?' . $lock, [$managedPath]);
if ($existing !== null) {
$owner = $existing['model_id'] === null ? null : (int) $existing['model_id'];
if (
$owner !== $modelId || $existing['hash'] !== $hash
|| $existing['size'] === null || (int) $existing['size'] !== $size
) {
throw new DomainException('Managed path already records a different file.');
}
return (int) $existing['id'];
}
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(
'INSERT INTO files (model_id, folder_id, name, managed_path, hash, size, mime_type, status)'
. ' VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING id',
[$modelId, $folder, basename($managedPath), $managedPath, $hash, $size, $mime, $status]
);
return (int) $file['id'];
});
}
public function aggregate(int $modelId): ?array
{
return $this->transaction(function () use ($modelId): ?array {
$model = $this->row('SELECT * FROM models WHERE id = ?', [$modelId]);
if ($model === null) {
return null;
}
return [
'model' => $model,
'folder' => $this->row('SELECT * FROM folders WHERE id = ?', [$model['folder_id']]),
'files' => $this->execute('SELECT * FROM files WHERE model_id = ? ORDER BY id', [$modelId])
->fetchAll(PDO::FETCH_ASSOC),
];
});
}
public function changeFileOwner(array $fileIds, ?int $expectedOwnerId, ?int $targetOwnerId): void
{
if ($fileIds === [] || !array_is_list($fileIds)) {
throw new InvalidArgumentException('A nonempty list of file IDs is required.');
}
$seen = [];
foreach ($fileIds as $id) {
if (!is_int($id) || $id <= 0 || isset($seen[$id])) {
throw new InvalidArgumentException('File IDs must be distinct positive integers.');
}
$seen[$id] = true;
}
foreach ([$expectedOwnerId, $targetOwnerId] as $owner) {
if ($owner !== null && $owner <= 0) {
throw new InvalidArgumentException('Model IDs must be positive or null.');
}
}
sort($fileIds, SORT_NUMERIC);
$this->transaction(function () use ($fileIds, $expectedOwnerId, $targetOwnerId): void {
foreach ([$expectedOwnerId, $targetOwnerId] as $owner) {
if ($owner !== null && $this->row('SELECT id FROM models WHERE id = ?', [$owner]) === null) {
throw new DomainException('Unknown model.');
}
}
$lock = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? ' FOR UPDATE' : '';
foreach ($fileIds as $id) {
$file = $this->row('SELECT model_id FROM files WHERE id = ?' . $lock, [$id]);
if ($file === null) {
throw new DomainException('Unknown file.');
}
$owner = $file['model_id'] === null ? null : (int) $file['model_id'];
if ($owner !== $expectedOwnerId) {
throw new DomainException('File ownership has changed.');
}
}
// Even a no-op must lock and validate the complete selection first.
if ($expectedOwnerId === $targetOwnerId) {
return;
}
foreach ($fileIds as $id) {
$this->execute('UPDATE files SET model_id = ? WHERE id = ?', [$targetOwnerId, $id]);
}
});
}
public function 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(
'UPDATE files SET status = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
[$targetStatus, $fileId]
);
return $this->file($fileId);
});
}
private function validateStatus(string $status): void
{
if (!in_array($status, ['Present', 'Missing', 'Updated', 'Duplicate'], true)) {
throw new InvalidArgumentException('Invalid file status.');
}
}
private function ensureFolder(string $path): int
{
$segments = array_values(array_filter(explode('/', str_replace('\\', '/', $path)), 'strlen'));
$segments = array_values(array_filter($segments, static fn (string $part): bool => $part !== '.'));
if (in_array('..', $segments, true)) {
throw new InvalidArgumentException('Folder traversal is not allowed.');
}
$parent = null;
$current = '';
foreach (array_merge([''], $segments) as $segment) {
$current = $segment === '' ? '/' : rtrim($current, '/') . '/' . $segment;
$this->execute(
'INSERT INTO folders (parent_id, name, path) VALUES (?, ?, ?) ON CONFLICT (path) DO NOTHING',
[$parent, $segment === '' ? '/' : $segment, $current]
);
$parent = (int) $this->row('SELECT id FROM folders WHERE path = ?', [$current])['id'];
}
return $parent;
}
}
+358
View File
@@ -0,0 +1,358 @@
<?php
declare(strict_types=1);
namespace MeshVault\Repository;
use DomainException;
use InvalidArgumentException;
use PDO;
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
{
if ($sourcePath === '') {
throw new InvalidArgumentException('Source path must not be empty.');
}
foreach ($items as $index => $item) {
if (!isset($item['source_path']) || !isset($item['size'])) {
throw new InvalidArgumentException("Item $index requires source_path and size.");
}
if ($item['size'] < 0) {
throw new InvalidArgumentException("Item $index size must be non-negative.");
}
$allowed = ['Pending', 'Copying', 'Completed', 'Failed', 'Skipped'];
if (isset($item['status']) && !in_array($item['status'], $allowed, true)) {
throw new InvalidArgumentException("Item $index has invalid status.");
}
}
return $this->transaction(function () use ($sourcePath, $items): int {
$job = $this->row(
'INSERT INTO import_jobs (source_path, total_files, total_bytes) VALUES (?, ?, ?) RETURNING id',
[$sourcePath, count($items), array_sum(array_column($items, 'size'))]
);
$jobId = (int) $job['id'];
if ($items === []) {
// Empty manifests stay claimable; completion is an explicit caller decision.
return $jobId;
}
$statement = $this->pdo->prepare(
'INSERT INTO import_items (job_id, source_path, size, model_id, status, failure_reason)'
. ' VALUES (?, ?, ?, ?, ?, ?)'
);
foreach ($items as $item) {
$status = $item['status'] ?? 'Pending';
$failureReason = $item['failure_reason'] ?? null;
$statement->execute([
$jobId,
$item['source_path'],
$item['size'],
$item['model_id'] ?? null,
$status,
$failureReason,
]);
}
// Scan-time failures arrive terminal, so publish progress immediately.
$this->refresh($jobId);
return $jobId;
});
}
public function get(int $jobId): ?array
{
return $this->row('SELECT * FROM import_jobs WHERE id = ?', [$jobId]);
}
public function items(int $jobId): array
{
return $this->execute('SELECT * FROM import_items WHERE job_id = ? ORDER BY id', [$jobId])
->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
{
return $this->transaction(function (): ?array {
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql') {
// SKIP LOCKED lets concurrent workers claim distinct jobs without blocking.
$candidate = $this->row(
"SELECT id FROM import_jobs WHERE status = 'Pending'"
. ' ORDER BY id FOR UPDATE SKIP LOCKED LIMIT 1'
);
if ($candidate === null) {
return null;
}
return $this->row(
"UPDATE import_jobs SET status = 'Copying', started_at = CURRENT_TIMESTAMP"
. ' WHERE id = ? RETURNING *',
[$candidate['id']]
);
}
// SQLite serializes writers, so a single conditional UPDATE is already atomic.
return $this->row(
"UPDATE import_jobs SET status = 'Copying', started_at = CURRENT_TIMESTAMP"
. " WHERE id = (SELECT id FROM import_jobs WHERE status = 'Pending' ORDER BY id LIMIT 1)"
. ' RETURNING *'
);
});
}
public function resumeInterrupted(): void
{
$this->transaction(function (): void {
// Plans (managed_path/hash) are deliberately preserved for idempotent recovery.
$this->execute(
"UPDATE import_items SET status = 'Pending', started_at = NULL WHERE status = 'Copying'"
. " AND job_id IN (SELECT id FROM import_jobs WHERE status IN ('Scanning', 'Copying'))"
);
$this->execute(
"UPDATE import_jobs SET status = 'Pending' WHERE status IN ('Scanning', 'Copying')"
);
});
}
public function planItem(int $itemId, string $managedPath, string $hash, int $size): void
{
if ($managedPath === '' || $hash === '' || $size < 0) {
throw new InvalidArgumentException('A plan requires a path, hash and non-negative size.');
}
$this->transaction(function () use ($itemId, $managedPath, $hash, $size): void {
$item = $this->lockedItem($itemId);
if (in_array($item['status'], ['Completed', 'Failed', 'Skipped'], true)) {
throw new DomainException('Cannot plan a terminal import item.');
}
if ($item['managed_path'] !== null) {
if (
$item['managed_path'] !== $managedPath || $item['hash'] !== $hash
|| (int) $item['size'] !== $size
) {
throw new DomainException('An import plan is immutable once recorded.');
}
return;
}
$this->execute(
"UPDATE import_items SET status = 'Copying', managed_path = ?, hash = ?, size = ?,"
. ' started_at = CURRENT_TIMESTAMP WHERE id = ?',
[$managedPath, $hash, $size, $itemId]
);
});
}
public function updateManagedPath(int $itemId, string $managedPath): void
{
if ($managedPath === '') {
throw new InvalidArgumentException('A managed path must not be empty.');
}
$this->transaction(function () use ($itemId, $managedPath): void {
$item = $this->lockedItem($itemId);
if (in_array($item['status'], ['Completed', 'Failed', 'Skipped'], true)) {
throw new DomainException('Cannot update a terminal import item.');
}
$this->execute(
'UPDATE import_items SET managed_path = ? WHERE id = ?',
[$managedPath, $itemId]
);
});
}
public function finishItem(int $itemId, string $status, ?string $failureReason = null): void
{
if (!in_array($status, ['Completed', 'Failed', 'Skipped'], true)) {
throw new InvalidArgumentException('Only terminal item statuses may finish an item.');
}
$this->transaction(function () use ($itemId, $status, $failureReason): void {
$item = $this->lockedItem($itemId);
if (in_array($item['status'], ['Completed', 'Failed', 'Skipped'], true)) {
if ($item['status'] !== $status) {
throw new DomainException('Import item already reached a different terminal status.');
}
return;
}
$this->execute(
'UPDATE import_items SET status = ?, failure_reason = ?, completed_at = CURRENT_TIMESTAMP'
. ' WHERE id = ?',
[$status, $failureReason, $itemId]
);
$this->refresh((int) $item['job_id']);
});
}
public function release(int $jobId): void
{
$this->transaction(function () use ($jobId): void {
$this->execute(
"UPDATE import_items SET status = 'Pending', started_at = NULL"
. " WHERE job_id = ? AND status = 'Copying'",
[$jobId]
);
$this->execute(
"UPDATE import_jobs SET status = 'Pending' WHERE id = ? AND status IN ('Scanning', 'Copying')",
[$jobId]
);
});
}
public function cancel(int $jobId): void
{
$this->terminate($jobId, 'Cancelled', 'Skipped', null);
}
public function fail(int $jobId, string $failureReason): void
{
if ($failureReason === '') {
throw new InvalidArgumentException('A failure reason is required.');
}
$this->terminate($jobId, 'Failed', 'Failed', $failureReason);
}
public function completeEmpty(int $jobId): void
{
$this->transaction(function () use ($jobId): void {
$job = $this->row('SELECT * FROM import_jobs WHERE id = ?', [$jobId]);
if ($job === null) {
throw new DomainException('Unknown import job.');
}
if ($this->items($jobId) !== []) {
throw new DomainException('Import job still has items.');
}
if (in_array($job['status'], ['Completed', 'Failed', 'Cancelled'], true)) {
return;
}
$this->execute(
"UPDATE import_jobs SET status = 'Completed', processed_files = 0,"
. ' completed_at = CURRENT_TIMESTAMP WHERE id = ?',
[$jobId]
);
});
}
public function atomic(callable $callback): mixed
{
return $this->transaction($callback);
}
/** Terminal job transitions share one shape: finish open items, then close the job. */
private function terminate(int $jobId, string $jobStatus, string $itemStatus, ?string $reason): void
{
$this->transaction(function () use ($jobId, $jobStatus, $itemStatus, $reason): void {
$job = $this->row('SELECT * FROM import_jobs WHERE id = ?', [$jobId]);
if ($job === null) {
throw new DomainException('Unknown import job.');
}
if (in_array($job['status'], ['Completed', 'Failed', 'Cancelled'], true)) {
return;
}
$this->execute(
'UPDATE import_items SET status = ?, failure_reason = ?, completed_at = CURRENT_TIMESTAMP'
. " WHERE job_id = ? AND status IN ('Pending', 'Copying')",
[$itemStatus, $reason, $jobId]
);
$this->execute(
'UPDATE import_jobs SET status = ?, processed_files = ?, copied_bytes = ?,'
. ' completed_at = CURRENT_TIMESTAMP WHERE id = ?',
[$jobStatus, $this->processedFiles($jobId), $this->copiedBytes($jobId), $jobId]
);
});
}
/** Progress is recomputed from items so repeated or crashed updates cannot drift. */
private function refresh(int $jobId): void
{
$job = $this->row('SELECT * FROM import_jobs WHERE id = ?', [$jobId]);
if ($job === null || in_array($job['status'], ['Completed', 'Failed', 'Cancelled'], true)) {
return;
}
$total = (int) $this->row('SELECT COUNT(*) AS total FROM import_items WHERE job_id = ?', [$jobId])['total'];
$processed = $this->processedFiles($jobId);
$failed = (int) $this->row(
"SELECT COUNT(*) AS failed FROM import_items WHERE job_id = ? AND status = 'Failed'",
[$jobId]
)['failed'];
if ($total === 0 || $processed < $total) {
$this->execute(
'UPDATE import_jobs SET processed_files = ?, copied_bytes = ? WHERE id = ?',
[$processed, $this->copiedBytes($jobId), $jobId]
);
return;
}
$this->execute(
'UPDATE import_jobs SET status = ?, processed_files = ?, copied_bytes = ?,'
. ' completed_at = CURRENT_TIMESTAMP WHERE id = ?',
[$failed > 0 ? 'Failed' : 'Completed', $processed, $this->copiedBytes($jobId), $jobId]
);
}
private function processedFiles(int $jobId): int
{
return (int) $this->row(
'SELECT COUNT(*) AS processed FROM import_items WHERE job_id = ?'
. " AND status IN ('Completed', 'Failed', 'Skipped')",
[$jobId]
)['processed'];
}
private function copiedBytes(int $jobId): int
{
return (int) $this->row(
"SELECT COALESCE(SUM(size), 0) AS copied FROM import_items WHERE job_id = ? AND status = 'Completed'",
[$jobId]
)['copied'];
}
private function lockedItem(int $itemId): array
{
$suffix = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql' ? ' FOR UPDATE' : '';
$item = $this->row('SELECT * FROM import_items WHERE id = ?' . $suffix, [$itemId]);
if ($item === null) {
throw new DomainException('Unknown import item.');
}
return $item;
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace MeshVault\Repository;
use PDO;
use PDOStatement;
use Throwable;
/** @internal Shared SQL and composable transaction support. */
abstract class PdoRepository
{
public function __construct(protected PDO $pdo)
{
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
}
protected function execute(string $sql, array $parameters = []): PDOStatement
{
$statement = $this->pdo->prepare($sql);
$statement->execute($parameters);
return $statement;
}
protected function row(string $sql, array $parameters = []): ?array
{
$statement = $this->execute($sql, $parameters);
$row = $statement->fetch(PDO::FETCH_ASSOC);
$statement->closeCursor();
return $row === false ? null : $row;
}
protected function transaction(callable $callback): mixed
{
$nested = $this->pdo->inTransaction();
$savepoint = 'repository_' . bin2hex(random_bytes(8));
if ($nested) {
$this->pdo->exec('SAVEPOINT ' . $savepoint);
} else {
$this->pdo->beginTransaction();
}
try {
$result = $callback();
if ($nested) {
$this->pdo->exec('RELEASE SAVEPOINT ' . $savepoint);
} else {
$this->pdo->commit();
}
return $result;
} catch (Throwable $error) {
if ($nested) {
$this->pdo->exec('ROLLBACK TO SAVEPOINT ' . $savepoint);
$this->pdo->exec('RELEASE SAVEPOINT ' . $savepoint);
} elseif ($this->pdo->inTransaction()) {
$this->pdo->rollBack();
}
throw $error;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
?>
<!doctype html>
<html lang="en">
<head><meta charset="utf-8"><title><?= $escape($data['title']) ?></title></head>
<body><h1><?= $escape($data['title']) ?></h1><pre><?= $escape($data['detail']) ?></pre></body>
</html>
+15
View File
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
?>
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?= $escape($data['title']) ?></title>
<link rel="stylesheet" href="/build/app.css">
</head>
<body><h1 class="text-blue-600"><?= $escape($data['title']) ?></h1><p>Your managed 3D model library.</p></body>
</html>
+113
View File
@@ -0,0 +1,113 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Http;
use MeshVault\Config\AppConfig;
use MeshVault\Http\AppFactory;
use MeshVault\Http\HomeHandler;
use MeshVault\Http\View;
use PHPUnit\Framework\TestCase;
use Slim\Psr7\Factory\ServerRequestFactory;
use Slim\Psr7\Response;
final class AppTest extends TestCase
{
private function app(string $environment = 'production'): \Slim\App
{
self::assertTrue(class_exists(AppFactory::class), 'HTTP AppFactory must exist');
return AppFactory::create(AppConfig::fromEnvironment([
'MESHVAULT_ENV' => $environment,
'MESHVAULT_LIBRARY_DIR' => sys_get_temp_dir(),
'MESHVAULT_DB_HOST' => 'db',
'MESHVAULT_DB_NAME' => 'meshvault',
'MESHVAULT_DB_USER' => 'meshvault',
'MESHVAULT_DB_PASSWORD' => 'private-secret',
]));
}
public function testHomeUsesContainerResolvedHandlerAndHtmlView(): void
{
$app = $this->app();
self::assertInstanceOf(\DI\Container::class, $app->getContainer());
self::assertInstanceOf(HomeHandler::class, $app->getContainer()->get(HomeHandler::class));
$app->getContainer()->set(View::class, new View(dirname(__DIR__, 2) . '/templates'));
$response = $app->handle((new ServerRequestFactory())->createServerRequest('GET', '/'));
self::assertSame(200, $response->getStatusCode());
self::assertSame('text/html; charset=utf-8', $response->getHeaderLine('Content-Type'));
self::assertStringContainsString('<h1 class="text-blue-600">MeshVault</h1>', (string) $response->getBody());
self::assertSame(HomeHandler::class, array_values($app->getRouteCollector()->getRoutes())[0]->getCallable());
}
public function testViewEscapesDynamicTextAndQuotes(): void
{
self::assertTrue(class_exists(View::class), 'PHP View must exist');
$html = (new View(dirname(__DIR__, 2) . '/templates'))->render('home', ['title' => '<script>"&\'</script>']);
self::assertStringContainsString('&lt;script&gt;&quot;&amp;&#039;&lt;/script&gt;', $html);
self::assertStringNotContainsString('<script>', $html);
}
public function testViewRejectsTraversalBeforeStartingOutputBuffer(): void
{
$level = ob_get_level();
try {
(new View(dirname(__DIR__, 2) . '/templates'))->render('../config/container', []);
self::fail('Traversal must be rejected');
} catch (\InvalidArgumentException $exception) {
self::assertSame('Unknown view.', $exception->getMessage());
self::assertSame($level, ob_get_level());
}
}
public function testViewRestoresOutputBufferWhenTemplateThrows(): void
{
$level = ob_get_level();
try {
(new View(dirname(__DIR__, 2) . '/templates'))->render('home', ['title' => new \stdClass()]);
self::fail('Invalid view value must throw');
} catch (\TypeError) {
self::assertSame($level, ob_get_level());
}
}
public function testNotFound(): void
{
$response = $this->app()->handle((new ServerRequestFactory())->createServerRequest('GET', '/missing'));
self::assertSame(404, $response->getStatusCode());
self::assertStringContainsString('Not Found', (string) $response->getBody());
}
public function testMethodNotAllowed(): void
{
$response = $this->app()->handle((new ServerRequestFactory())->createServerRequest('POST', '/'));
self::assertSame(405, $response->getStatusCode());
self::assertStringContainsString('GET', $response->getHeaderLine('Allow'));
}
public function testProductionExceptionIsGeneric(): void
{
$app = $this->app();
$app->get('/test-only', function (): never {
throw new \RuntimeException('private-secret <script>failure</script>');
});
$response = $app->handle((new ServerRequestFactory())->createServerRequest('GET', '/test-only'));
self::assertSame(500, $response->getStatusCode());
self::assertStringContainsString('Internal Server Error', (string) $response->getBody());
self::assertStringNotContainsString('private-secret', (string) $response->getBody());
self::assertStringNotContainsString('AppTest', (string) $response->getBody());
}
public function testDevelopmentExceptionIsReadableAndEscaped(): void
{
$app = $this->app('development');
$app->get('/test-only', function (): never {
throw new \RuntimeException('Readable <script>failure</script>');
});
$response = $app->handle((new ServerRequestFactory())->createServerRequest('GET', '/test-only'));
self::assertSame(500, $response->getStatusCode());
$body = (string) $response->getBody();
self::assertStringContainsString('Readable &lt;script&gt;failure&lt;/script&gt;', $body);
self::assertStringNotContainsString('<script>', (string) $response->getBody());
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Http;
use PHPUnit\Framework\TestCase;
final class BootstrapTest extends TestCase
{
private function runPhp(string $file, string $mode, bool $valid): array
{
$env = array_merge(getenv(), [
'MESHVAULT_ENV' => $mode,
'MESHVAULT_LIBRARY_DIR' => sys_get_temp_dir(),
'MESHVAULT_DB_HOST' => $valid ? 'db' : '',
'MESHVAULT_DB_NAME' => 'meshvault',
'MESHVAULT_DB_USER' => 'meshvault',
'MESHVAULT_DB_PASSWORD' => 'bootstrap-secret',
]);
$code = 'register_shutdown_function(function () { fwrite(STDERR, "STATUS=" . http_response_code()); });'
. 'require ' . var_export($file, true) . ';';
$process = proc_open([PHP_BINARY, '-r', $code], [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, $env);
$out = stream_get_contents($pipes[1]);
$err = stream_get_contents($pipes[2]);
fclose($pipes[1]);
fclose($pipes[2]);
return [proc_close($process), $out, $err];
}
public function testProductionBootstrapFailureIsGeneric500(): void
{
[$exit, $out, $err] = $this->runPhp('public/index.php', 'production', false);
self::assertStringContainsString('Internal Server Error', $out);
self::assertStringContainsString('STATUS=500', $err);
self::assertStringNotContainsString('MESHVAULT_DB_HOST', $out);
self::assertStringNotContainsString('bootstrap-secret', $out . $err);
self::assertStringContainsString('MESHVAULT_DB_HOST', $err);
}
public function testDevelopmentBootstrapFailureIsReadable(): void
{
[$exit, $out, $err] = $this->runPhp('public/index.php', 'development', false);
self::assertStringContainsString('MESHVAULT_DB_HOST', $out);
self::assertStringContainsString('STATUS=500', $err);
self::assertStringNotContainsString('bootstrap-secret', $out . $err);
}
public function testConfigCliSucceedsOnlyForValidConfig(): void
{
self::assertFileExists('bin/check-config.php');
[$exit, $out, $err] = $this->runPhp('bin/check-config.php', 'production', true);
self::assertSame(0, $exit, $err);
[$exit, $out, $err] = $this->runPhp('bin/check-config.php', 'production', false);
self::assertSame(1, $exit);
self::assertStringContainsString('MESHVAULT_DB_HOST', $err);
self::assertStringNotContainsString('bootstrap-secret', $out . $err);
}
}
+40
View File
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Http;
use PHPUnit\Framework\TestCase;
final class DeploymentTest extends TestCase
{
public function testFpmConfigExistsAndForwardsUndecoratedWorkerLogs(): void
{
$path = dirname(__DIR__, 2) . '/docker/php/zz-meshvault.conf';
self::assertFileExists($path);
$config = file_get_contents($path);
self::assertStringContainsString('clear_env = no', $config);
self::assertStringContainsString('catch_workers_output = yes', $config);
self::assertStringContainsString('decorate_workers_output = no', $config);
}
public function testComposeSuppliesEveryApplicationSettingAndViewDirectory(): void
{
$compose = file_get_contents(dirname(__DIR__, 2) . '/docker-compose.yml');
$app = explode(' db:', explode(' app:', $compose, 2)[1], 2)[0];
foreach (['ENV', 'LOG_LEVEL', 'DB_HOST', 'DB_PORT', 'DB_NAME', 'DB_USER', 'DB_PASSWORD'] as $key) {
self::assertStringContainsString('MESHVAULT_' . $key . ':', $app);
}
foreach (['config', 'templates'] as $directory) {
self::assertStringContainsString('./' . $directory . ':/var/www/html/' . $directory . ':ro', $app);
}
}
public function testBuildContextIncludesDependencyAndStartupInputs(): void
{
$ignore = file_get_contents(dirname(__DIR__, 2) . '/.dockerignore');
foreach (['composer.json', 'composer.lock', 'bin/', 'bin/**', 'docker/php/**'] as $input) {
self::assertStringContainsString('!' . $input, $ignore);
}
}
}
@@ -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);
}
}
+433
View File
@@ -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'],
]);
}
}
+385
View File
@@ -0,0 +1,385 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use DomainException;
use InvalidArgumentException;
use MeshVault\Repository\PdoCatalogRepository;
use PDO;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class CatalogCoreTest extends TestCase
{
private PDO $pdo;
private PdoCatalogRepository $catalog;
private string $database;
protected function setUp(): void
{
$this->database = tempnam(sys_get_temp_dir(), 'meshvault-core-');
$this->pdo = RepositoryDatabase::migrate($this->database);
$this->catalog = new PdoCatalogRepository($this->pdo);
}
protected function tearDown(): void
{
unset($this->catalog, $this->pdo);
gc_collect_cycles();
RepositoryDatabase::cleanup($this->database);
}
public function testExplicitIdentityAndLegacyCountedLookup(): void
{
$first = $this->catalog->ensureModel('/parts', ' Plane ');
self::assertSame($first, $this->catalog->ensureModel('/parts', ' Plane '));
$second = $this->catalog->createModel('/parts', ' Plane ');
self::assertNotSame($first, $second);
foreach ([$first, $second] as $id) {
$aggregate = $this->catalog->aggregate($id);
self::assertSame(' Plane ', $aggregate['model']['name']);
self::assertSame('/parts', $aggregate['folder']['path']);
self::assertSame([], $aggregate['files']);
}
self::assertNull($this->catalog->aggregate(999999));
try {
$this->catalog->ensureModel('/parts', ' Plane ');
self::fail('Ambiguous legacy lookup must fail');
} catch (DomainException) {
self::assertSame(2, $this->countRows('models'));
}
}
#[DataProvider('invalidModels')]
public function testInvalidModelInputsWriteNothing(string $folder, string $title): void
{
foreach (['createModel', 'ensureModel'] as $method) {
try {
$this->catalog->$method($folder, $title);
self::fail('Invalid model must fail');
} catch (InvalidArgumentException) {
self::assertSame(0, $this->countRows('models'));
self::assertSame(0, $this->countRows('folders'));
}
}
}
public static function invalidModels(): array
{
return [['', 'Title'], ['/', ''], ['/', " \t\n"], ['/', str_repeat('a', 256)],
['/', str_repeat('é', 256)], ['../escape', 'Title']];
}
public function testUnicodeTitleLimitCountsCharactersAndRootIsValid(): void
{
foreach (['createModel', 'ensureModel'] as $method) {
$id = $this->catalog->$method('/', str_repeat('é', 255));
self::assertSame(str_repeat('é', 255), $this->catalog->aggregate($id)['model']['name']);
self::assertSame('/', $this->catalog->aggregate($id)['folder']['path']);
}
}
public function testNullableRegistrationIsIdempotentWithoutChangingMetadata(): void
{
$id = $this->catalog->recordFile(null, 'parts/é.stl', 'hash', 12, 'model/stl', 'Missing');
$before = $this->pdo->query('SELECT * FROM files')->fetch(PDO::FETCH_ASSOC);
self::assertNull($before['model_id']);
self::assertSame($id, $this->catalog->recordFile(null, 'parts/é.stl', 'hash', 12));
self::assertSame($before, $this->pdo->query('SELECT * FROM files')->fetch(PDO::FETCH_ASSOC));
$model = $this->catalog->createModel('/elsewhere', 'Title');
$owned = $this->catalog->recordFile($model, 'other.stl', 'hash', 12);
self::assertSame($owned, $this->catalog->recordFile($model, 'other.stl', 'hash', 12));
self::assertSame('/elsewhere', $this->catalog->aggregate($model)['folder']['path']);
}
#[DataProvider('registrationConflicts')]
public function testRegistrationNeverAdoptsOrOverwrites(string $conflict): void
{
$model = $this->catalog->ensureModel('/', 'Title');
$owner = $conflict === 'detach' ? $model : null;
$this->catalog->recordFile($owner, 'part.stl', 'hash', 12);
$before = $this->pdo->query('SELECT * FROM files')->fetchAll(PDO::FETCH_ASSOC);
try {
$this->catalog->recordFile(
$conflict === 'adopt' ? $model : null,
'part.stl',
$conflict === 'hash' ? 'different' : 'hash',
$conflict === 'size' ? 13 : 12
);
self::fail('Conflicting registration must fail');
} catch (DomainException) {
self::assertSame($before, $this->pdo->query('SELECT * FROM files')->fetchAll(PDO::FETCH_ASSOC));
}
}
public static function registrationConflicts(): array
{
return [['adopt'], ['detach'], ['hash'], ['size']];
}
#[DataProvider('invalidRegistrations')]
public function testInvalidRegistrationWritesNothing(?int $owner, string $path, string $hash, int $size): void
{
try {
$this->catalog->recordFile($owner, $path, $hash, $size);
self::fail('Invalid registration must fail');
} catch (InvalidArgumentException) {
self::assertSame(0, $this->countRows('files'));
self::assertSame(0, $this->countRows('folders'));
}
}
public static function invalidRegistrations(): array
{
$cases = [];
foreach (['', '/absolute', 'a\\b', 'a//b', 'a/./b', 'a/../b', "a\0b", 'https://host/a'] as $path) {
$cases[] = [null, $path, 'hash', 1];
}
return array_merge($cases, [[0, 'a', 'hash', 1], [-1, 'a', 'hash', 1],
[null, 'a', '', 1], [null, 'a', 'hash', -1]]);
}
public function testAtomicReturnsValuesAndPreservesCallerTransaction(): void
{
$this->pdo->beginTransaction();
$outer = $this->catalog->ensureModel('/', 'Outer');
$id = $this->catalog->atomic(fn () => $this->catalog->createModel('/', 'Inner'));
self::assertNotSame($outer, $id);
self::assertTrue($this->pdo->inTransaction());
try {
$this->catalog->atomic(function (): void {
$this->catalog->createModel('/rollback', 'Rollback');
$this->catalog->recordFile(null, 'rollback/file.stl', 'hash', 1);
throw new RuntimeException('abort');
});
self::fail('Expected failure');
} catch (RuntimeException $error) {
self::assertSame('abort', $error->getMessage());
}
self::assertTrue($this->pdo->inTransaction());
self::assertSame(2, $this->countRows('models'));
self::assertSame(1, $this->countRows('folders'));
self::assertSame(0, $this->countRows('files'));
$this->pdo->rollBack();
self::assertSame(0, $this->countRows('models'));
self::assertSame('result', $this->catalog->atomic(static fn () => 'result'));
self::assertFalse($this->pdo->inTransaction());
}
#[DataProvider('invalidAssociations')]
public function testInvalidAssociationArguments(array $ids, ?int $expected, ?int $target): void
{
try {
$this->catalog->changeFileOwner($ids, $expected, $target);
self::fail('Invalid association must fail');
} catch (InvalidArgumentException) {
self::assertSame(0, $this->countRows('files'));
self::assertFalse($this->pdo->inTransaction());
}
}
public static function invalidAssociations(): array
{
return [[[], null, 1], [[1, 1], null, 1], [['1'], null, 1], [[1.0], null, 1],
[[true], null, 1], [[null], null, 1], [[0], null, 1], [[-1], null, 1],
[[1], 0, 1], [[1], -1, 1], [[1], null, 0], [[1], null, -1], [[2 => 1], null, 1]];
}
#[DataProvider('associationConflicts')]
public function testAssociationConflictsPreserveEveryRow(string $case): void
{
$first = $this->catalog->createModel('/first', 'First');
$second = $this->catalog->createModel('/second', 'Second');
$one = $this->catalog->recordFile($first, 'one.stl', 'one', 1);
$two = $this->catalog->recordFile($case === 'mixed-owner' ? $second : $first, 'two.stl', 'two', 2);
$before = $this->fileRows();
$expected = match ($case) {
'missing-expected' => 999999,
'null-expected' => null,
'stale-expected' => $second,
default => $first,
};
try {
$this->catalog->changeFileOwner(
[$one, $case === 'missing-file' ? 999999 : $two],
$expected,
$case === 'missing-target' ? 999999 : $second
);
self::fail('Conflict must fail');
} catch (DomainException) {
self::assertSame($before, $this->fileRows());
self::assertFalse($this->pdo->inTransaction());
}
}
public static function associationConflicts(): array
{
return array_map(static fn (string $case): array => [$case], [
'missing-expected', 'missing-target', 'missing-file', 'null-expected', 'stale-expected', 'mixed-owner',
]);
}
public function testNestedAssociationSuccessRemainsOwnedByCaller(): void
{
$service = new \MeshVault\Catalog\CatalogService($this->catalog);
$model = $service->createModel('/', 'Target');
$file = $this->catalog->recordFile(null, 'one.stl', 'hash', 1);
$before = $this->fileRows();
$this->pdo->beginTransaction();
$service->assignFiles($model, [$file]);
self::assertTrue($this->pdo->inTransaction());
self::assertSame($model, (int) $this->fileRows()[0]['model_id']);
$this->pdo->rollBack();
self::assertSame($before, $this->fileRows());
}
#[DataProvider('failureTransactions')]
public function testLaterUpdateFailureRollsBackEarlierUpdates(bool $nested, bool $throughService): void
{
$service = new \MeshVault\Catalog\CatalogService($this->catalog);
$model = $service->createModel('/', 'Target');
$one = $this->catalog->recordFile(null, 'one.stl', 'one', 1);
$two = $this->catalog->recordFile(null, 'two.stl', 'two', 2);
$before = $this->fileRows();
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql') {
$this->pdo->exec('CREATE FUNCTION reject_later_update() RETURNS trigger LANGUAGE plpgsql AS $$'
. ' BEGIN IF NEW.id = ' . $two
. ' AND (SELECT model_id FROM files WHERE id = ' . $one . ') = ' . $model
. " THEN RAISE EXCEPTION 'later update rejected'; END IF; RETURN NEW; END; $$");
$this->pdo->exec('CREATE TRIGGER fail_later BEFORE UPDATE OF model_id ON files'
. ' FOR EACH ROW EXECUTE FUNCTION reject_later_update()');
} else {
$this->pdo->exec('CREATE TRIGGER fail_later BEFORE UPDATE OF model_id ON files WHEN NEW.id = ' . $two
. ' AND (SELECT model_id FROM files WHERE id = ' . $one . ') = ' . $model
. " BEGIN SELECT RAISE(ABORT, 'later update rejected'); END");
}
if ($nested) {
$this->pdo->beginTransaction();
$outer = $service->createModel('/outer', 'Outer');
}
try {
if ($throughService) {
$service->assignFiles($model, [$two, $one]);
} else {
$this->catalog->changeFileOwner([$two, $one], null, $model);
}
self::fail('Later SQL update must fail');
} catch (\PDOException $error) {
self::assertStringContainsString('later update rejected', $error->getMessage());
}
self::assertSame($before, $this->fileRows());
self::assertSame($nested, $this->pdo->inTransaction());
if ($nested) {
self::assertNotNull($service->aggregate($outer));
$this->pdo->commit();
self::assertNotNull($service->aggregate($outer));
}
}
public static function failureTransactions(): array
{
return [[false, false], [true, false], [false, true], [true, true]];
}
public function testServiceAssociationsPreservePhysicalFilesAndMetadata(): void
{
$service = new \MeshVault\Catalog\CatalogService($this->catalog);
$first = $service->createModel('/home', 'Multipart');
$second = $service->createModel('/other-home', 'Destination');
$root = dirname(__DIR__, 2) . '/.superpowers/catalog-core/files-' . bin2hex(random_bytes(6));
mkdir($root, 0777, true);
$paths = ['parts/one.stl', 'parts/two.stl', 'images/selected.png', 'docs/selected.pdf', 'docs/unselected.pdf'];
$ids = $physical = [];
try {
foreach ($paths as $path) {
if (!is_dir(dirname($root . '/' . $path))) {
mkdir(dirname($root . '/' . $path));
}
$bytes = "fixture\0" . $path;
file_put_contents($root . '/' . $path, $bytes);
touch($root . '/' . $path, 1000000000);
$physical[$path] = [file_get_contents($root . '/' . $path), filemtime($root . '/' . $path)];
$ids[] = $this->catalog->recordFile(null, $path, hash('sha256', $bytes), strlen($bytes));
}
$before = $this->fileRows();
$selected = array_slice($ids, 0, 4);
$service->assignFiles($first, array_reverse($selected));
self::assertSame($selected, array_column($service->aggregate($first)['files'], 'id'));
$this->catalog->changeFileOwner($selected, $first, $first);
$service->reassignFiles($first, $second, $selected);
self::assertSame([], $service->aggregate($first)['files']);
self::assertSame('/home', $service->aggregate($first)['folder']['path']);
self::assertSame('/other-home', $service->aggregate($second)['folder']['path']);
self::assertSame($selected, array_column($service->aggregate($second)['files'], 'id'));
$after = $this->fileRows();
foreach ($after as $index => $row) {
self::assertSame($index < 4 ? $second : null, $row['model_id']);
$row['model_id'] = null;
self::assertSame($before[$index], $row);
}
$service->detachFiles($second, $selected);
$this->catalog->changeFileOwner($selected, null, null);
self::assertSame($before, $this->fileRows());
self::assertSame([], $service->aggregate($second)['files']);
self::assertNull($service->aggregate(999999));
foreach ($physical as $path => $snapshot) {
clearstatcache(true, $root . '/' . $path);
self::assertSame($snapshot, [
file_get_contents($root . '/' . $path), filemtime($root . '/' . $path),
]);
}
} finally {
foreach ($paths as $path) {
if (is_file($root . '/' . $path)) {
unlink($root . '/' . $path);
}
}
foreach (['parts', 'images', 'docs'] as $folder) {
if (is_dir($root . '/' . $folder)) {
rmdir($root . '/' . $folder);
}
}
rmdir($root);
}
}
public function testContainerResolvesServiceWithSharedRepositoryAndPdo(): void
{
$config = \MeshVault\Config\AppConfig::fromEnvironment([
'MESHVAULT_LIBRARY_DIR' => dirname(__DIR__, 2), 'MESHVAULT_DB_HOST' => 'unused',
'MESHVAULT_DB_NAME' => 'unused', 'MESHVAULT_DB_USER' => 'unused',
'MESHVAULT_DB_PASSWORD' => 'unused',
]);
$definitions = require dirname(__DIR__, 2) . '/config/container.php';
$builder = new \DI\ContainerBuilder();
$builder->useAutowiring(false);
$builder->addDefinitions($definitions($config));
$builder->addDefinitions([PDO::class => $this->pdo]);
$container = $builder->build();
self::assertTrue($container->has(\MeshVault\Catalog\CatalogService::class));
$service = $container->get(\MeshVault\Catalog\CatalogService::class);
$repository = $container->get(\MeshVault\Repository\CatalogRepositoryInterface::class);
self::assertSame($repository, (new \ReflectionObject($service))->getProperty('catalog')->getValue($service));
$this->pdo->beginTransaction();
$model = $service->createModel('/', 'Container');
$file = $repository->recordFile(null, 'container.stl', 'hash', 1);
$service->assignFiles($model, [$file]);
self::assertCount(1, $service->aggregate($model)['files']);
self::assertTrue($this->pdo->inTransaction());
$this->pdo->rollBack();
self::assertNull($service->aggregate($model));
}
private function fileRows(): array
{
return $this->pdo->query('SELECT * FROM files ORDER BY id')->fetchAll(PDO::FETCH_ASSOC);
}
private function countRows(string $table): int
{
return (int) $this->pdo->query('SELECT COUNT(*) FROM ' . $table)->fetchColumn();
}
}
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use ArrayObject;
use PDOStatement;
/** Records execution, including the lock attempt that throws, rather than just SQL preparation. */
final class CatalogLockStatement extends PDOStatement
{
protected function __construct(private ArrayObject $locks)
{
}
public function execute(?array $params = null): bool
{
if (preg_match('/SELECT .* FROM files WHERE id = \? FOR UPDATE$/i', $this->queryString)) {
$this->locks[] = $params;
}
return parent::execute($params);
}
}
+202
View File
@@ -0,0 +1,202 @@
<?php
declare(strict_types=1);
namespace MeshVault\Tests\Integration;
use PDO;
use PDOException;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use RuntimeException;
final class CatalogMigrationTest extends TestCase
{
#[DataProvider('badPaths')]
public function testUpgradePreflightLeavesEverythingIntact(string $path, bool $duplicate): void
{
if ($duplicate) {
$this->pdo->exec("INSERT INTO files (id, model_id, folder_id, name, managed_path)
VALUES (31, 21, 10, 'other', 'parts/part.stl')");
} else {
$statement = $this->pdo->prepare('UPDATE files SET managed_path = ? WHERE id = 30');
$statement->execute([$path]);
}
$this->assertPreflightFails(false, $duplicate ? ['30', '31'] : ['30']);
}
public static function badPaths(): array
{
return [['parts/part.stl', true], ['a/../part.stl', false], ['a//part.stl', false], ['C:/part.stl', false]];
}
#[DataProvider('downgradeConflicts')]
public function testDowngradePreflightLeavesEverythingIntact(bool $unassigned): void
{
RepositoryDatabase::migrateTo($this->path, self::NEW);
if ($unassigned) {
$this->pdo->exec('UPDATE files SET model_id = NULL WHERE id = 30');
} else {
$this->pdo->exec("UPDATE models SET name = 'First' WHERE id = 21");
}
$this->assertPreflightFails(true, $unassigned ? ['30'] : ['20', '21']);
}
public static function downgradeConflicts(): array
{
return [[true], [false]];
}
private function assertPreflightFails(bool $down, array $ids): void
{
$before = $this->snapshot();
try {
if ($down) {
RepositoryDatabase::rollbackTo($this->path, self::OLD);
} else {
RepositoryDatabase::migrateTo($this->path, self::NEW);
}
self::fail('Expected preflight rejection');
} catch (RuntimeException $error) {
foreach ($ids as $id) {
self::assertStringContainsString($id, $error->getMessage());
}
self::assertStringContainsString('Resolve', $error->getMessage());
}
self::assertSame($before, $this->snapshot());
}
private function rows(): array
{
$rows = [];
$tables = ['folders', 'models', 'files', 'tags', 'model_tags', 'thumbnails', 'import_jobs', 'import_items'];
foreach ($tables as $table) {
$rows[$table] = $this->pdo->query('SELECT * FROM ' . $table . ' ORDER BY 1')->fetchAll(PDO::FETCH_ASSOC);
}
return $rows;
}
private function indexes(): array
{
$indexes = [];
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') {
foreach (['files', 'models'] as $table) {
foreach ($this->pdo->query('PRAGMA index_list(' . $table . ')')->fetchAll(PDO::FETCH_ASSOC) as $row) {
$indexes[$table][$row['name']] = (int) $row['unique'];
}
ksort($indexes[$table]);
}
}
return $indexes;
}
private function snapshot(): array
{
$snapshot = [$this->rows(), $this->pdo->query('SELECT * FROM phinxlog ORDER BY version')->fetchAll()];
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') {
$snapshot[] = $this->pdo->query('SELECT * FROM sqlite_master ORDER BY type, name')->fetchAll();
foreach (['files', 'models'] as $table) {
$snapshot[] = $this->pdo->query('PRAGMA foreign_key_list(' . $table . ')')->fetchAll();
$snapshot[] = $this->pdo->query('PRAGMA index_list(' . $table . ')')->fetchAll();
}
}
return $snapshot;
}
private function assertRejected(string $sql): void
{
try {
$this->pdo->exec($sql);
self::fail('Expected database constraint rejection');
} catch (PDOException) {
self::assertTrue(true);
}
}
private const OLD = 20261011120006;
private const NEW = 20261012120001;
private string $directory;
private string $path;
private PDO $pdo;
protected function setUp(): void
{
$this->directory = RepositoryDatabase::directory('catalog-migration');
$this->path = $this->directory . '/catalog.sqlite';
$this->pdo = RepositoryDatabase::migrate($this->path, self::OLD);
$this->pdo->exec("INSERT INTO folders (id, name, path) VALUES (10, 'root', '/')");
$this->pdo->exec("INSERT INTO models (id, folder_id, name, notes) VALUES
(20, 10, 'First', 'keep'), (21, 10, 'Second', 'also keep')");
$this->pdo->exec("INSERT INTO files (id, model_id, folder_id, name, managed_path, hash, size)
VALUES (30, 20, 10, 'part.stl', 'parts/part.stl', 'abc', 123)");
// Explicit fixture IDs do not advance PostgreSQL sequences.
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'pgsql') {
foreach (['folders' => 10, 'models' => 21, 'files' => 30] as $table => $id) {
$this->pdo->query("SELECT setval(pg_get_serial_sequence('$table', 'id'), $id, true)")->closeCursor();
}
}
$this->pdo->exec("INSERT INTO tags (id, name) VALUES (40, 'tag')");
$this->pdo->exec('INSERT INTO model_tags (model_id, tag_id) VALUES (20, 40)');
$this->pdo->exec("INSERT INTO thumbnails (model_id, managed_path, hash, size, mime_type)
VALUES (20, 'thumb.png', 'thumbhash', 12, 'image/png')");
$this->pdo->exec("INSERT INTO import_jobs (id, source_path) VALUES (50, 'source')");
$this->pdo->exec("INSERT INTO import_items (job_id, source_path, model_id) VALUES (50, 'part.stl', 20)");
}
protected function tearDown(): void
{
unset($this->pdo);
RepositoryDatabase::cleanup($this->path);
RepositoryDatabase::remove($this->directory);
}
public function testRoundTripPreservesRowsAndConstraints(): void
{
$before = $this->rows();
$oldIndexes = $this->indexes();
RepositoryDatabase::migrateTo($this->path, self::NEW);
$newIndexes = $this->indexes();
if ($newIndexes !== []) {
self::assertSame(1, $newIndexes['files']['idx_files_managed_path']);
self::assertSame(0, $newIndexes['files']['idx_files_model_id']);
self::assertSame(0, $newIndexes['models']['idx_models_folder_name']);
foreach (['idx_files_hash', 'idx_files_status', 'idx_files_folder_id'] as $index) {
self::assertSame($oldIndexes['files'][$index], $newIndexes['files'][$index]);
}
self::assertSame(
$oldIndexes['models']['idx_models_added_at'],
$newIndexes['models']['idx_models_added_at']
);
}
self::assertSame($before, $this->rows());
$this->assertRejected("INSERT INTO files (model_id, folder_id, name, managed_path)
VALUES (NULL, 10, 'duplicate', 'parts/part.stl')");
$this->assertRejected('UPDATE files SET model_id = 999 WHERE id = 30');
$this->assertRejected('DELETE FROM models WHERE id = 20');
$this->assertRejected('DELETE FROM folders WHERE id = 10');
$this->pdo->exec('UPDATE files SET model_id = NULL WHERE id = 30');
$this->assertRejected("INSERT INTO files (model_id, folder_id, name, managed_path)
VALUES (NULL, 10, 'duplicate', 'parts/part.stl')");
$this->pdo->exec('UPDATE files SET model_id = 20 WHERE id = 30');
RepositoryDatabase::rollbackTo($this->path, self::OLD);
self::assertSame($oldIndexes, $this->indexes());
self::assertSame($before, $this->rows());
$this->assertRejected('UPDATE files SET model_id = NULL WHERE id = 30');
$this->assertRejected("INSERT INTO models (folder_id, name) VALUES (10, 'First')");
RepositoryDatabase::migrateTo($this->path, self::NEW);
self::assertSame($before, $this->rows());
$model = $this->pdo->query("INSERT INTO models (folder_id, name) VALUES (10, 'First') RETURNING id");
self::assertGreaterThan(21, (int) $model->fetchColumn());
$model->closeCursor();
$file = $this->pdo->query("INSERT INTO files (model_id, folder_id, name, managed_path)
VALUES (NULL, 10, 'new', 'new.stl') RETURNING id");
self::assertGreaterThan(30, (int) $file->fetchColumn());
$file->closeCursor();
$this->pdo->exec('UPDATE files SET model_id = NULL WHERE id = 30');
$this->pdo->exec('DELETE FROM models WHERE id = 20');
self::assertSame(2, (int) $this->pdo->query('SELECT COUNT(*) FROM files')->fetchColumn());
if ($this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME) === 'sqlite') {
self::assertSame([], $this->pdo->query('PRAGMA foreign_key_check')->fetchAll());
}
}
}
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more