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