fix(import): report manifest shards the archive is missing

A shard listed in the manifest but absent from the zip was filtered out
silently, so the surviving shards were treated as the whole export:
inspection undercounted it and the job reported success having skipped
every conversation in the missing file.
This commit is contained in:
Marco Beretta 2026-07-29 03:03:24 +02:00
parent 29f39b32de
commit ea8cf18f61
No known key found for this signature in database
GPG key ID: D918033D8E74CC11
3 changed files with 68 additions and 6 deletions

View file

@ -255,3 +255,33 @@ describe('detectExportFormat', () => {
expect(detectExportFormat('nope')).toBeNull();
});
});
describe('resolveLayout missing shards', () => {
it('reports a manifest-listed shard the archive does not contain', () => {
const manifest = parseManifest(
Buffer.from(
JSON.stringify({
version: 1,
logical_files: {
'conversations.json': {
files: ['conversations-000.json', 'conversations-001.json'],
sharded: true,
},
},
}),
),
);
const layout = resolveLayout([{ name: 'conversations-000.json', bytes: 2 }], manifest);
expect(layout.conversationShards).toEqual(['conversations-000.json']);
expect(layout.missingShards).toEqual(['conversations-001.json']);
});
it('reports nothing missing when the filename fallback is used', () => {
const layout = resolveLayout([{ name: 'conversations.json', bytes: 2 }], null);
expect(layout.conversationShards).toEqual(['conversations.json']);
expect(layout.missingShards).toEqual([]);
});
});

View file

@ -21,6 +21,11 @@ export interface ExportManifest {
export interface ExportLayout {
version: number | null;
conversationShards: string[];
/** Shards the manifest lists that the archive does not contain. Dropping
* them silently made a truncated export look complete: the remaining shards
* were treated as the whole thing, so inspection undercounted and the job
* reported success having skipped the missing conversations. */
missingShards: string[];
assetNames: string | null;
assetEntries: ArchiveEntry[];
}
@ -58,12 +63,25 @@ export function parseManifest(buffer: Buffer): ExportManifest | null {
}
}
function shardsFromManifest(manifest: ExportManifest, present: Set<string>): string[] {
function shardsFromManifest(
manifest: ExportManifest,
present: Set<string>,
): { found: string[]; missing: string[] } {
const logical = manifest.logical_files[CONVERSATIONS_LOGICAL];
if (!logical?.files || !Array.isArray(logical.files) || logical.files.length === 0) {
return [];
return { found: [], missing: [] };
}
return logical.files.filter((name) => present.has(name));
const found: string[] = [];
const missing: string[] = [];
for (const name of logical.files) {
if (present.has(name)) {
found.push(name);
continue;
}
missing.push(name);
}
return { found, missing };
}
function grokShards(entries: ArchiveEntry[]): string[] {
@ -187,13 +205,20 @@ export function resolveLayout(
): ExportLayout {
const present = new Set(entries.map((entry) => entry.name));
const fromManifest = manifest ? shardsFromManifest(manifest, present) : [];
const conversationShards =
fromManifest.length > 0 ? fromManifest : shardsFromFilenames(entries, present);
const fromManifest = manifest
? shardsFromManifest(manifest, present)
: { found: [], missing: [] };
const useManifest = fromManifest.found.length > 0;
const conversationShards = useManifest
? fromManifest.found
: shardsFromFilenames(entries, present);
return {
version: manifest?.version ?? null,
conversationShards,
/** Only meaningful when the manifest was actually used: the filename
* fallback has no declared list to be missing from. */
missingShards: useManifest ? fromManifest.missing : [],
assetNames: present.has(ASSET_NAMES_ENTRY) ? ASSET_NAMES_ENTRY : null,
assetEntries: entries.filter((entry) => entry.name.endsWith('.dat')),
};

View file

@ -223,6 +223,13 @@ export async function runImport(input: RunImportInput): Promise<ImportReport> {
const manifest = hasManifest ? parseManifest(await archive.read(MANIFEST_ENTRY)) : null;
const layout = resolveLayout(archive.entries, manifest);
/** A shard the manifest declares but the archive omits is missing data, not
* an absent feature: without this the run imports what survived and reports
* success, and the user never learns which conversations were skipped. */
for (const shard of layout.missingShards) {
report.errors.push(`${shard}: listed in the manifest but missing from the archive`);
}
const providerRun: ProviderImportContext = {
archive,
shards: layout.conversationShards,