feat: M3 import & indexing engine delivery layer with durable collision handling, space checking and console
This commit is contained in:
1 parent
93d991703c
commit
9d277d9e72
48 files changed
+5147
-4
No files matched your search
@@ -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];
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user