From ea8cf18f61aac7709bac87ccd790248989cb805e Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Wed, 29 Jul 2026 03:03:24 +0200 Subject: [PATCH] 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. --- packages/api/src/import/manifest.spec.ts | 30 +++++++++++++++++++ packages/api/src/import/manifest.ts | 37 ++++++++++++++++++++---- packages/api/src/import/service.ts | 7 +++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/packages/api/src/import/manifest.spec.ts b/packages/api/src/import/manifest.spec.ts index 48bf711792..1d5be186dd 100644 --- a/packages/api/src/import/manifest.spec.ts +++ b/packages/api/src/import/manifest.spec.ts @@ -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([]); + }); +}); diff --git a/packages/api/src/import/manifest.ts b/packages/api/src/import/manifest.ts index 59710dae42..e7258e1623 100644 --- a/packages/api/src/import/manifest.ts +++ b/packages/api/src/import/manifest.ts @@ -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[] { +function shardsFromManifest( + manifest: ExportManifest, + present: Set, +): { 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')), }; diff --git a/packages/api/src/import/service.ts b/packages/api/src/import/service.ts index 89f7bb9e82..b0d06a4091 100644 --- a/packages/api/src/import/service.ts +++ b/packages/api/src/import/service.ts @@ -223,6 +223,13 @@ export async function runImport(input: RunImportInput): Promise { 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,