@@ -45,27 +45,25 @@ function detectFenceLanguage(filePath: string): string {
4545 return "markdown" ;
4646}
4747
48- // `readable` is false when this directory or any descendant could not be read
48+ // `unreadableDirs` lists the exact directories whose contents could not be read
4949// (an unmounted/undocked volume), which is distinct from a readable-but-empty
50- // directory. It propagates up so a transiently-gone nested mount is not mistaken
51- // for deletion; callers use it to skip pruning during an outage (#97523).
50+ // directory. They bubble up so the caller preserves only the unavailable subtree
51+ // during an outage, while readable siblings still prune normally (#97523).
5252async function listAllowedFilesRecursive (
5353 rootDir : string ,
54- ) : Promise < { files : string [ ] ; readable : boolean } > {
54+ ) : Promise < { files : string [ ] ; unreadableDirs : string [ ] } > {
5555 const entries = await fs . readdir ( rootDir , { withFileTypes : true } ) . catch ( ( ) => null ) ;
5656 if ( entries === null ) {
57- return { files : [ ] , readable : false } ;
57+ return { files : [ ] , unreadableDirs : [ rootDir ] } ;
5858 }
5959 const files : string [ ] = [ ] ;
60- let readable = true ;
60+ const unreadableDirs : string [ ] = [ ] ;
6161 for ( const entry of entries ) {
6262 const fullPath = path . join ( rootDir , entry . name ) ;
6363 if ( entry . isDirectory ( ) ) {
6464 const nested = await listAllowedFilesRecursive ( fullPath ) ;
6565 files . push ( ...nested . files ) ;
66- if ( ! nested . readable ) {
67- readable = false ;
68- }
66+ unreadableDirs . push ( ...nested . unreadableDirs ) ;
6967 continue ;
7068 }
7169 if (
@@ -75,32 +73,30 @@ async function listAllowedFilesRecursive(
7573 files . push ( fullPath ) ;
7674 }
7775 }
78- return { files : files . toSorted ( ( left , right ) => left . localeCompare ( right ) ) , readable } ;
76+ return { files : files . toSorted ( ( left , right ) => left . localeCompare ( right ) ) , unreadableDirs } ;
7977}
8078
8179async function collectUnsafeLocalArtifacts (
8280 configuredPaths : string [ ] ,
83- ) : Promise < { artifacts : UnsafeLocalArtifact [ ] ; unreadableRoots : string [ ] } > {
81+ ) : Promise < { artifacts : UnsafeLocalArtifact [ ] ; unreadablePaths : string [ ] } > {
8482 const artifacts : UnsafeLocalArtifact [ ] = [ ] ;
85- // Configured roots that could not be fully read (undocked drive, unmounted
86- // NAS, not-yet-mounted nested mount, or a missing explicit file) are
87- // transiently absent, not deleted. The caller preserves imported entries under
88- // these roots from pruning while still cleaning up readable roots (#97523).
89- const unreadableRoots : string [ ] = [ ] ;
83+ // Exact paths that could not be read (undocked drive, unmounted NAS,
84+ // not-yet-mounted nested mount, or a missing explicit file) are transiently
85+ // absent, not deleted. The caller preserves imported entries under these paths
86+ // from pruning while readable siblings still clean up deletions (#97523).
87+ const unreadablePaths : string [ ] = [ ] ;
9088 for ( const configuredPath of configuredPaths ) {
9189 const absoluteConfiguredPath = path . resolve ( configuredPath ) ;
9290 const stat = await fs . stat ( absoluteConfiguredPath ) . catch ( ( ) => null ) ;
9391 if ( ! stat ) {
94- unreadableRoots . push ( absoluteConfiguredPath ) ;
92+ unreadablePaths . push ( absoluteConfiguredPath ) ;
9593 continue ;
9694 }
9795 if ( stat . isDirectory ( ) ) {
9896 const listing = await listAllowedFilesRecursive ( absoluteConfiguredPath ) ;
99- // A nested mount that is gone still imports the files we can read, but the
100- // root is marked unreadable so its entries are not pruned during the outage.
101- if ( ! listing . readable ) {
102- unreadableRoots . push ( absoluteConfiguredPath ) ;
103- }
97+ // A gone nested mount still imports the files we can read; only the exact
98+ // unreadable subtrees are preserved so readable siblings still prune.
99+ unreadablePaths . push ( ...listing . unreadableDirs ) ;
104100 for ( const absolutePath of listing . files ) {
105101 artifacts . push ( {
106102 syncKey : await resolveArtifactKey ( absolutePath ) ,
@@ -125,33 +121,34 @@ async function collectUnsafeLocalArtifacts(
125121 for ( const artifact of artifacts ) {
126122 deduped . set ( artifact . syncKey , artifact ) ;
127123 }
128- return { artifacts : [ ...deduped . values ( ) ] , unreadableRoots } ;
124+ return { artifacts : [ ...deduped . values ( ) ] , unreadablePaths } ;
129125}
130126
131- function isPathAtOrWithin ( target : string , root : string ) : boolean {
132- if ( target === root ) {
127+ function isPathAtOrWithin ( target : string , base : string ) : boolean {
128+ if ( target === base ) {
133129 return true ;
134130 }
135- const rootWithSep = root . endsWith ( path . sep ) ? root : `${ root } ${ path . sep } ` ;
136- return target . startsWith ( rootWithSep ) ;
131+ const baseWithSep = base . endsWith ( path . sep ) ? base : `${ base } ${ path . sep } ` ;
132+ return target . startsWith ( baseWithSep ) ;
137133}
138134
139- // Keep imported entries whose source lives under a transiently unreadable root
135+ // Keep imported entries whose source lives under a transiently unreadable path
140136// out of the prune by marking their keys active, so a temporary outage cannot
141- // delete their pages or human-notes while readable roots still clean up (#97523).
142- function preserveEntriesUnderUnreadableRoots ( params : {
137+ // delete their pages or human-notes while readable siblings still clean up
138+ // genuinely deleted files (#97523).
139+ function preserveEntriesUnderUnreadablePaths ( params : {
143140 state : Awaited < ReturnType < typeof readMemoryWikiSourceSyncState > > ;
144- unreadableRoots : string [ ] ;
141+ unreadablePaths : string [ ] ;
145142 activeKeys : Set < string > ;
146143} ) : void {
147- if ( params . unreadableRoots . length === 0 ) {
144+ if ( params . unreadablePaths . length === 0 ) {
148145 return ;
149146 }
150147 for ( const [ syncKey , entry ] of Object . entries ( params . state . entries ) ) {
151148 if ( entry . group !== "unsafe-local" ) {
152149 continue ;
153150 }
154- if ( params . unreadableRoots . some ( ( root ) => isPathAtOrWithin ( entry . sourcePath , root ) ) ) {
151+ if ( params . unreadablePaths . some ( ( base ) => isPathAtOrWithin ( entry . sourcePath , base ) ) ) {
155152 params . activeKeys . add ( syncKey ) ;
156153 }
157154 }
@@ -268,7 +265,7 @@ export async function syncMemoryWikiUnsafeLocalSources(
268265 } ;
269266 }
270267
271- const { artifacts, unreadableRoots } = await collectUnsafeLocalArtifacts (
268+ const { artifacts, unreadablePaths } = await collectUnsafeLocalArtifacts (
272269 config . unsafeLocal . paths ,
273270 ) ;
274271 const state = await readMemoryWikiSourceSyncState ( config . vault . path ) ;
@@ -292,12 +289,12 @@ export async function syncMemoryWikiUnsafeLocalSources(
292289 } ) ,
293290 ) ;
294291
295- // A transiently unreadable root collects zero artifacts the same way a real
292+ // A transiently unreadable path collects zero artifacts the same way a real
296293 // deletion does, so its entries would otherwise be pruned, hard-deleting their
297- // pages and human-notes blocks with nothing left to restore. Preserve those
298- // entries per-root , then prune normally so readable roots still clean up their
299- // genuinely deleted files. See #97523.
300- preserveEntriesUnderUnreadableRoots ( { state, unreadableRoots , activeKeys } ) ;
294+ // pages and human-notes blocks with nothing left to restore. Preserve only the
295+ // entries under the exact unreadable subtrees , then prune normally so readable
296+ // siblings still clean up their genuinely deleted files. See #97523.
297+ preserveEntriesUnderUnreadablePaths ( { state, unreadablePaths , activeKeys } ) ;
301298 const removedCount = await pruneImportedSourceEntries ( {
302299 vaultRoot : config . vault . path ,
303300 group : "unsafe-local" ,
0 commit comments