Merge remote-tracking branch 'origin/catalog-shared-contracts'
This commit is contained in:
commit
6a2b9079e9
16 files changed
+1244
-44
No files matched your search
@@ -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.
|
||||
@@ -1,3 +1,17 @@
|
||||
## 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
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user