feat: M3 import & indexing engine delivery layer with durable collision handling, space checking and console

This commit is contained in:
Cline committed 2026-10-12 11:29:17 -05:00
1 parent 93d991703c
commit 9d277d9e72
48 files changed
+5147 -4

No files matched your search

+94
View File
@@ -0,0 +1,94 @@
<?php
declare(strict_types=1);
namespace MeshVault\Indexer;
use MeshVault\Indexer\Contracts\FileEnumeratorInterface;
use MeshVault\Repository\CatalogRepositoryInterface;
use MeshVault\Repository\ImportJobRepositoryInterface;
use Throwable;
/**
* Turns a source tree into a durable import job manifest.
*
* Grouping policy (explicit, never inferred from file contents): 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 CatalogRepositoryInterface $catalog,
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): 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(
fn (): int => $this->jobs->create($sourcePath, $this->manifest($sourcePath, $scan))
);
} 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
{
$models = [];
$items = [];
foreach ($scan->files as $file) {
$group = $this->group($sourcePath, $file);
$models[$group] ??= $this->catalog->ensureModel('/' . $group, $group);
$items[] = [
'source_path' => $file->sourcePath,
'size' => $file->size,
'model_id' => $models[$group],
];
}
// 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;
}
private function group(string $sourcePath, ScannedFile $file): string
{
$parent = trim(str_replace('\\', '/', dirname($file->relativePath)), '/');
if ($parent === '' || $parent === '.') {
return basename(rtrim(str_replace('\\', '/', $sourcePath), '/'));
}
return explode('/', $parent)[0];
}
}