feat: establish catalog identity and atomic file ownership

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

No files matched your search

@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
use MeshVault\Catalog\ManagedPath;
use Phinx\Migration\AbstractMigration;
/** Writers must be stopped for both directions, including the read-only preflight. */
final class CatalogIdentity extends AbstractMigration
{
public function up(): void
{
$problems = [];
foreach ($this->fetchAll('SELECT id, managed_path FROM files ORDER BY id') as $file) {
try {
ManagedPath::validate($file['managed_path']);
} catch (InvalidArgumentException) {
$problems[] = 'noncanonical managed path on file ID ' . $file['id'];
}
}
$duplicates = $this->fetchAll('SELECT f.id FROM files f INNER JOIN
(SELECT managed_path FROM files GROUP BY managed_path HAVING COUNT(*) > 1) d
ON f.managed_path = d.managed_path ORDER BY f.id');
if ($duplicates !== []) {
$problems[] = 'duplicate managed paths on file IDs ' . implode(', ', array_column($duplicates, 'id'));
}
$this->reject(
$problems,
'Resolve paths and ownership explicitly; do not merge or delete records automatically.'
);
$this->table('files')
->dropForeignKey('model_id')
->changeColumn('model_id', 'integer', ['null' => true, 'signed' => false])
->addForeignKey('model_id', 'models', 'id', ['delete' => 'RESTRICT', 'update' => 'NO_ACTION'])
->removeIndexByName('idx_files_model_path')
->addIndex(['managed_path'], ['unique' => true, 'name' => 'idx_files_managed_path'])
->addIndex(['model_id'], ['name' => 'idx_files_model_id'])
->update();
$this->table('models')
->removeIndexByName('idx_models_folder_name')
->addIndex(['folder_id', 'name'], ['name' => 'idx_models_folder_name'])
->update();
}
public function down(): void
{
$problems = [];
$unassigned = $this->fetchAll('SELECT id FROM files WHERE model_id IS NULL ORDER BY id');
if ($unassigned !== []) {
$problems[] = 'unassigned file IDs ' . implode(', ', array_column($unassigned, 'id'));
}
$duplicates = $this->fetchAll('SELECT m.id FROM models m INNER JOIN
(SELECT folder_id, name FROM models GROUP BY folder_id, name HAVING COUNT(*) > 1) d
ON m.folder_id = d.folder_id AND m.name = d.name ORDER BY m.id');
if ($duplicates !== []) {
$problems[] = 'duplicate same-folder titles on model IDs ' . implode(', ', array_column($duplicates, 'id'));
}
$this->reject(
$problems,
'Resolve by explicitly assigning files and disambiguating model titles before retrying.'
);
$this->table('files')
->dropForeignKey('model_id')
->changeColumn('model_id', 'integer', ['null' => false, 'signed' => false])
->addForeignKey('model_id', 'models', 'id', ['delete' => 'CASCADE', 'update' => 'NO_ACTION'])
->removeIndexByName('idx_files_managed_path')
->removeIndexByName('idx_files_model_id')
->addIndex(['model_id', 'managed_path'], ['unique' => true, 'name' => 'idx_files_model_path'])
->update();
$this->table('models')
->removeIndexByName('idx_models_folder_name')
->addIndex(['folder_id', 'name'], ['unique' => true, 'name' => 'idx_models_folder_name'])
->update();
}
private function reject(array $problems, string $guidance): void
{
if ($problems !== []) {
throw new RuntimeException('Catalog identity preflight: ' . implode('; ', $problems) . '. ' . $guidance);
}
}
}
+57
View File
@@ -5,3 +5,60 @@ changes versioned and repeatable; do not introduce a competing runner here.
Subsequent issues #13-18 add physical folders, logical models, distinct files,
metadata and import jobs. Application added_at timestamps are separate from file
mtime. Migrations must never implicitly delete underlying library files.
## Catalog identity — 20261012120001
Back up the database and **stop all writers** (web requests, import workers and
other database clients) before either upgrade or downgrade. Keep them stopped
through preflight and DDL. Preflight is not a concurrency lock.
Upgrade rejects duplicate managed paths and noncanonical paths, even without a
collision, reporting offending file IDs. It makes file ownership nullable with
RESTRICT on model deletion, replaces idx_files_model_path with unique
idx_files_managed_path and nonunique idx_files_model_id, and makes
idx_models_folder_name nonunique. Explicitly detach files before model deletion.
Rows, IDs, timestamps, dependent metadata and unrelated indexes/FKs are preserved.
Managed paths are lexical slash-separated relative names, preserved verbatim.
Empty/dot/dot-dot segments, backslashes, NUL, absolute paths and URI/drive prefixes
are rejected. Windows additionally rejects colons and trailing dots/spaces in
segments. Case and Unicode are not normalized. This is not filesystem case
comparison or symlink authorization; PathSafety still governs I/O. Migration
validation performs no filesystem access.
Investigate reported IDs and explicitly resolve catalog paths and ownership
against the library before retrying. No automatic merging, renaming, reassignment
or deletion repairs conflicts.
Downgrade to 20261011120006 rejects unassigned file IDs and duplicate same-folder
model titles before DDL. Explicitly assign files and disambiguate titles first.
Only then are NOT NULL, per-model path uniqueness, unique folder/title and the
old CASCADE model FK restored. Preflight failure leaves schema, data and migration
version unchanged.
Tasks 1–3 restore repository/importer compatibility and add the bounded catalog
service: explicit same-title identities, ambiguity-safe legacy lookup, nullable
registration and expected-owner associations using savepoint-backed transactions.
Association changes update only model_id, not physical files or home folders.
Task 4 verified the full suite on 2026-10-13:
- Windows PHP 8.3.35: foundation passed; PHPUnit 377 tests / 2032 assertions /
28 skips; standard lint passed (Composer script commands invoked directly).
- Fresh isolated PostgreSQL 17 / PHP 8.4.26 via `sh bin/test-postgres.sh`: Composer
tests 377 / 2160 / 7 skips and lint passed. Log on the Docker host:
`keith@192.168.2.23:/tmp/meshvault-catalog-tests-Ov96uNzU/pg-run-3.log`.
- The suite runs production migrations in generated schemas, including populated
down/up, dependent metadata, direct constraints, index preservation and unchanged
preflight-rejection snapshots. This does not replace an operator backup/restore
drill or deployment migration smoke acceptance.
- First PG run exposed four SQLite-trigger syntax errors in rollback tests;
dialect-specific test triggers now prove rollback after an actual earlier write.
Six new two-session cases verify 55P03 contention, stale/no-op behavior, ascending
execution parameters, nested rollback/lock release and root creation serialization.
They passed existing locking code; no induced deadlock or mutation-red claim.
No development database or historical workspace was touched. Independent full-slice
review approved this bounded implementation; #28 is not complete. Importer confirmation/recovery, broader
metadata/lifecycle contracts and unresolved M2/M3 acceptance remain open. See
`docs/project-status.md` for the evidence and limitations.