@@ -67,7 +67,7 @@ const __filename = fileURLToPath(import.meta.url);
6767const __dirname = path . dirname ( __filename ) ;
6868const PROJECT_ROOT = path . resolve ( __dirname , '../../..' ) ;
6969export const LOCAL_AI_CONFIG_FILE = path . join ( PROJECT_ROOT , 'ai' , 'config.mjs' ) ;
70- const DEFRAG_STATE_DIR = path . join ( PROJECT_ROOT , '.neo-ai-data' , 'maintenance' , 'defrag-state' ) ;
70+ const DEFRAG_STATE_DIR = path . join ( PROJECT_ROOT , '.neo-ai-data' , 'maintenance' , 'defrag-state' ) ;
7171const MEMORY_CORE_UNSAFE_MESSAGE =
7272 'Memory Core defrag is disabled until a safe multi-collection shadow/parking promotion exists. ' +
7373 'MC is an irreplaceable store; use backup/restore or a purpose-built repair lane instead of delete/recreate defrag.' ;
@@ -325,7 +325,7 @@ export async function cleanOldBackups(backupDir, retention = resolveDefragSnapsh
325325 */
326326async function getDirSize ( dir ) {
327327 const files = await fs . readdir ( dir , { withFileTypes : true } ) ;
328- let size = 0 ;
328+ let size = 0 ;
329329
330330 for ( const file of files ) {
331331 const filePath = path . join ( dir , file . name ) ;
@@ -669,14 +669,16 @@ export async function rewriteCollectionViaShadowPromotion({
669669 * @param {Object } options.embeddingFunction Chroma embedding function (dummy, for raw-vector moves).
670670 * @param {String } options.statePath Durable defrag-state marker path.
671671 * @param {Object } [options.stateBase={}] Stable fields written into every phase marker.
672+ * @param {Boolean } [options.dryRun=false] True extracts/re-embeds and reports counts without shadow promotion or state-marker writes.
672673 * @param {Function } [options.auditFn=auditChromaVectorCoverage] Enumeration seam (test injection).
673674 * @param {Function } [options.extractFn=extractMemoryCoreCollectionData] Extract + re-embed seam.
674675 * @param {Function } [options.promoteFn=rewriteCollectionViaShadowPromotion] Shadow-promotion seam.
675676 * @param {Function } [options.clearStateFn=clearDefragState] Clears the durable marker on a fully successful repair.
676677 * @param {Function } [options.writeStateFn=writeDefragState] Rewrites the explicit aborted marker on a partial repair.
677678 * @param {Function } [options.log=console.log] Log sink.
678- * @returns {Promise<{results: Object[]}> } Per collection: `{collectionName, promotion, counts}` on success
679- * or `{collectionName, aborted: true, unrecoverable, counts}` when fail-loud aborts the promotion.
679+ * @returns {Promise<{results: Object[]}> } Per collection: `{collectionName, promotion, counts}` on success,
680+ * `{collectionName, dryRun: true, counts}` on clean dry-run, or
681+ * `{collectionName, aborted: true, unrecoverable, counts}` when fail-loud aborts the promotion/report.
680682 */
681683export async function repairMemoryCoreCollectionsViaFullEnumeration ( {
682684 client,
@@ -687,6 +689,7 @@ export async function repairMemoryCoreCollectionsViaFullEnumeration({
687689 embeddingFunction,
688690 statePath,
689691 stateBase = { } ,
692+ dryRun = false ,
690693 auditFn = auditChromaVectorCoverage ,
691694 extractFn = extractMemoryCoreCollectionData ,
692695 promoteFn = rewriteCollectionViaShadowPromotion ,
@@ -708,9 +711,21 @@ export async function repairMemoryCoreCollectionsViaFullEnumeration({
708711 throw new Error ( `repairMemoryCoreCollectionsViaFullEnumeration: no coverage row for '${ collectionName } ' — refusing to promote a collection the enumeration never saw.` ) ;
709712 }
710713
711- const { allIds, missingVectorIds} = cov ,
712- collection = await client . getCollection ( { name : collectionName , embeddingFunction} ) ,
713- { data, unrecoverable, counts} = await extractFn ( { collection, allIds, missingVectorIds, embedFn} ) ;
714+ const { allIds, missingVectorIds} = cov ,
715+ collection = await client . getCollection ( { name : collectionName , embeddingFunction} ) ,
716+ { data, unrecoverable, counts} = await extractFn ( { collection, allIds, missingVectorIds, embedFn} ) ;
717+
718+ if ( dryRun ) {
719+ if ( unrecoverable . length > 0 ) {
720+ log ( ` ⚠️ '${ collectionName } ': DRY-RUN found ${ unrecoverable . length } unrecoverable row(s) (document-less / metadata-absent) — no promotion attempted. Counts: ${ JSON . stringify ( counts ) } ` ) ;
721+ results . push ( { collectionName, dryRun : true , aborted : true , unrecoverable, counts} ) ;
722+ continue ;
723+ }
724+
725+ log ( ` 🧪 '${ collectionName } ': DRY-RUN extraction/re-embed succeeded; no promotion attempted. Counts: ${ JSON . stringify ( counts ) } ` ) ;
726+ results . push ( { collectionName, dryRun : true , counts} ) ;
727+ continue ;
728+ }
714729
715730 // Fail-loud: never promote a collection that lost rows to unrecoverable extraction.
716731 if ( unrecoverable . length > 0 ) {
@@ -727,7 +742,7 @@ export async function repairMemoryCoreCollectionsViaFullEnumeration({
727742 // wrote durable per-phase markers, so a fully successful repair MUST clear the marker — else the next run aborts
728743 // as DEFRAG_INCOMPLETE_STATE. An aborted/partial repair instead rewrites an explicit aborted marker so
729744 // assertNoIncompleteDefragState blocks rerun with an accurate diagnostic, not a stale mid-phase marker.
730- if ( statePath ) {
745+ if ( statePath && ! dryRun ) {
731746 if ( anyRepairAborted ( results ) ) {
732747 await writeStateFn ( { statePath, state : {
733748 ...stateBase ,
@@ -777,6 +792,7 @@ async function defragChromaDB() {
777792 . description ( 'Defragment ChromaDB instances by rewriting data and cleaning orphaned files.' )
778793 . requiredOption ( '-t, --target <name>' , 'Database target (knowledge-base, memory-core)' )
779794 . option ( '--allow-memory-core' , 'Opt in to the Memory Core repair-defrag path (default: fails closed)' )
795+ . option ( '--dry-run' , 'For memory-core, run full enumeration/extraction report without shadow promotion' )
780796 . parse ( process . argv ) ;
781797
782798 const options = program . opts ( ) ;
@@ -848,23 +864,30 @@ async function defragChromaDB() {
848864 // defrag state marker on clean success (or rewrites an explicit aborted marker on a partial repair)
849865 // before this branch returns ahead of the KB path.
850866 if ( targetName === 'memory-core' ) {
851- console . log ( `\n3️⃣ Memory Core repair-defrag: full-enumeration extract + re-embed + shadow-promote...` ) ;
867+ const dryRun = options . dryRun === true ;
868+
869+ console . log ( dryRun
870+ ? `\n3️⃣ Memory Core repair-defrag DRY-RUN: full-enumeration extract + re-embed report (no shadow promotion)...`
871+ : `\n3️⃣ Memory Core repair-defrag: full-enumeration extract + re-embed + shadow-promote...` ) ;
852872 const { default : TextEmbeddingService } = await import ( '../../services/memory-core/TextEmbeddingService.mjs' ) ;
853- const { results} = await repairMemoryCoreCollectionsViaFullEnumeration ( {
873+ const { results} = await repairMemoryCoreCollectionsViaFullEnumeration ( {
854874 client,
855875 collections : config . collections ,
856876 snapshotPath : path . join ( backupPath , 'chroma.sqlite3' ) ,
857877 persistDir : backupPath ,
858878 embedFn : docs => TextEmbeddingService . embedTexts ( docs , config . embeddingProvider ) ,
859879 embeddingFunction : dummyEf ,
860880 statePath,
861- stateBase : { targetName}
881+ stateBase : { targetName} ,
882+ dryRun
862883 } ) ;
863884
864885 for ( const result of results ) {
865886 console . log ( result . aborted
866- ? ` ⚠️ ${ result . collectionName } : ABORTED — ${ result . unrecoverable . length } unrecoverable row(s); counts ${ JSON . stringify ( result . counts ) } `
867- : ` ✅ ${ result . collectionName } : repaired + promoted; counts ${ JSON . stringify ( result . counts ) } ` ) ;
887+ ? ` ⚠️ ${ result . collectionName } : ${ dryRun ? 'DRY-RUN WOULD ABORT' : 'ABORTED' } — ${ result . unrecoverable . length } unrecoverable row(s); counts ${ JSON . stringify ( result . counts ) } `
888+ : dryRun
889+ ? ` 🧪 ${ result . collectionName } : dry-run report clean; no promotion; counts ${ JSON . stringify ( result . counts ) } `
890+ : ` ✅ ${ result . collectionName } : repaired + promoted; counts ${ JSON . stringify ( result . counts ) } ` ) ;
868891 }
869892
870893 const finalSize = await getDirSize ( DB_PATH ) ;
@@ -874,16 +897,18 @@ async function defragChromaDB() {
874897 // (mirrors the KB extractionErrors / hasRestoreErrors -> process.exit(1) discipline below).
875898 if ( anyRepairAborted ( results ) ) {
876899 const abortedNames = results . filter ( result => result . aborted ) . map ( result => result . collectionName ) ;
877- console . error ( `❌ Memory Core repair aborted for ${ abortedNames . join ( ', ' ) } (unrecoverable rows) — NOT a successful repair; resolve the unrecoverable rows and re-run. Counts logged above.` ) ;
900+ console . error ( dryRun
901+ ? `❌ Memory Core repair dry-run found unrecoverable rows for ${ abortedNames . join ( ', ' ) } — no promotion was attempted; resolve the unrecoverable rows before running the mutating repair. Counts logged above.`
902+ : `❌ Memory Core repair aborted for ${ abortedNames . join ( ', ' ) } (unrecoverable rows) — NOT a successful repair; resolve the unrecoverable rows and re-run. Counts logged above.` ) ;
878903 process . exit ( 1 ) ;
879904 }
880905 return ;
881906 }
882907
883908 // 3. Extract All Data (Multi-Collection)
884909 console . log ( `\n3️⃣ Fetching data from all collections...` ) ;
885- const buffer = { } ;
886- let extractionErrors = false ;
910+ const buffer = { } ;
911+ let extractionErrors = false ;
887912
888913 for ( const colName of config . collections ) {
889914 console . log ( ` Processing collection: ${ colName } ` ) ;
@@ -900,7 +925,7 @@ async function defragChromaDB() {
900925
901926 // 3.1 Fetch all IDs first (avoids HNSW index to prevent "Error finding id")
902927 const allIds = [ ] ;
903- let offset = 0 ;
928+ let offset = 0 ;
904929 while ( true ) {
905930 const batch = await collection . get ( { limit : 2000 , offset, include : [ ] } ) ;
906931 if ( batch . ids . length === 0 ) break ;
0 commit comments